ParserTestRunner.php 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804
  1. <?php
  2. /**
  3. * Generic backend for the MediaWiki parser test suite, used by both the
  4. * standalone parserTests.php and the PHPUnit "parsertests" suite.
  5. *
  6. * Copyright © 2004, 2010 Brion Vibber <brion@pobox.com>
  7. * https://www.mediawiki.org/
  8. *
  9. * This program is free software; you can redistribute it and/or modify
  10. * it under the terms of the GNU General Public License as published by
  11. * the Free Software Foundation; either version 2 of the License, or
  12. * (at your option) any later version.
  13. *
  14. * This program is distributed in the hope that it will be useful,
  15. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. * GNU General Public License for more details.
  18. *
  19. * You should have received a copy of the GNU General Public License along
  20. * with this program; if not, write to the Free Software Foundation, Inc.,
  21. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  22. * http://www.gnu.org/copyleft/gpl.html
  23. *
  24. * @todo Make this more independent of the configuration (and if possible the database)
  25. * @file
  26. * @ingroup Testing
  27. */
  28. use Wikimedia\Rdbms\IDatabase;
  29. use MediaWiki\MediaWikiServices;
  30. use MediaWiki\Tidy\TidyDriverBase;
  31. use Wikimedia\ScopedCallback;
  32. use Wikimedia\TestingAccessWrapper;
  33. /**
  34. * @ingroup Testing
  35. */
  36. class ParserTestRunner {
  37. /**
  38. * MediaWiki core parser test files, paths
  39. * will be prefixed with __DIR__ . '/'
  40. *
  41. * @var array
  42. */
  43. private static $coreTestFiles = [
  44. 'parserTests.txt',
  45. 'extraParserTests.txt',
  46. ];
  47. /**
  48. * @var bool $useTemporaryTables Use temporary tables for the temporary database
  49. */
  50. private $useTemporaryTables = true;
  51. /**
  52. * @var array $setupDone The status of each setup function
  53. */
  54. private $setupDone = [
  55. 'staticSetup' => false,
  56. 'perTestSetup' => false,
  57. 'setupDatabase' => false,
  58. 'setDatabase' => false,
  59. 'setupUploads' => false,
  60. ];
  61. /**
  62. * Our connection to the database
  63. * @var Database
  64. */
  65. private $db;
  66. /**
  67. * Database clone helper
  68. * @var CloneDatabase
  69. */
  70. private $dbClone;
  71. /**
  72. * @var TidySupport
  73. */
  74. private $tidySupport;
  75. /**
  76. * @var TidyDriverBase
  77. */
  78. private $tidyDriver = null;
  79. /**
  80. * @var TestRecorder
  81. */
  82. private $recorder;
  83. /**
  84. * The upload directory, or null to not set up an upload directory
  85. *
  86. * @var string|null
  87. */
  88. private $uploadDir = null;
  89. /**
  90. * The name of the file backend to use, or null to use MockFileBackend.
  91. * @var string|null
  92. */
  93. private $fileBackendName;
  94. /**
  95. * A complete regex for filtering tests.
  96. * @var string
  97. */
  98. private $regex;
  99. /**
  100. * A list of normalization functions to apply to the expected and actual
  101. * output.
  102. * @var array
  103. */
  104. private $normalizationFunctions = [];
  105. /**
  106. * Run disabled parser tests
  107. * @var bool
  108. */
  109. private $runDisabled;
  110. /**
  111. * Run tests intended only for parsoid
  112. * @var bool
  113. */
  114. private $runParsoid;
  115. /**
  116. * Disable parse on article insertion
  117. * @var bool
  118. */
  119. private $disableSaveParse;
  120. /**
  121. * Reuse upload directory
  122. * @var bool
  123. */
  124. private $keepUploads;
  125. /**
  126. * @param TestRecorder $recorder
  127. * @param array $options
  128. */
  129. public function __construct( TestRecorder $recorder, $options = [] ) {
  130. $this->recorder = $recorder;
  131. if ( isset( $options['norm'] ) ) {
  132. foreach ( $options['norm'] as $func ) {
  133. if ( in_array( $func, [ 'removeTbody', 'trimWhitespace' ] ) ) {
  134. $this->normalizationFunctions[] = $func;
  135. } else {
  136. $this->recorder->warning(
  137. "Warning: unknown normalization option \"$func\"\n" );
  138. }
  139. }
  140. }
  141. if ( isset( $options['regex'] ) && $options['regex'] !== false ) {
  142. $this->regex = $options['regex'];
  143. } else {
  144. # Matches anything
  145. $this->regex = '//';
  146. }
  147. $this->keepUploads = !empty( $options['keep-uploads'] );
  148. $this->fileBackendName = $options['file-backend'] ?? false;
  149. $this->runDisabled = !empty( $options['run-disabled'] );
  150. $this->runParsoid = !empty( $options['run-parsoid'] );
  151. $this->disableSaveParse = !empty( $options['disable-save-parse'] );
  152. $this->tidySupport = new TidySupport( !empty( $options['use-tidy-config'] ) );
  153. if ( !$this->tidySupport->isEnabled() ) {
  154. $this->recorder->warning(
  155. "Warning: tidy is not installed, skipping some tests\n" );
  156. }
  157. if ( isset( $options['upload-dir'] ) ) {
  158. $this->uploadDir = $options['upload-dir'];
  159. }
  160. }
  161. /**
  162. * Get list of filenames to extension and core parser tests
  163. *
  164. * @return array
  165. */
  166. public static function getParserTestFiles() {
  167. global $wgParserTestFiles;
  168. // Add core test files
  169. $files = array_map( function ( $item ) {
  170. return __DIR__ . "/$item";
  171. }, self::$coreTestFiles );
  172. // Plus legacy global files
  173. $files = array_merge( $files, $wgParserTestFiles );
  174. // Auto-discover extension parser tests
  175. $registry = ExtensionRegistry::getInstance();
  176. foreach ( $registry->getAllThings() as $info ) {
  177. $dir = dirname( $info['path'] ) . '/tests/parser';
  178. if ( !file_exists( $dir ) ) {
  179. continue;
  180. }
  181. $counter = 1;
  182. $dirIterator = new RecursiveIteratorIterator(
  183. new RecursiveDirectoryIterator( $dir )
  184. );
  185. foreach ( $dirIterator as $fileInfo ) {
  186. /** @var SplFileInfo $fileInfo */
  187. if ( substr( $fileInfo->getFilename(), -4 ) === '.txt' ) {
  188. $name = $info['name'] . $counter;
  189. while ( isset( $files[$name] ) ) {
  190. $name = $info['name'] . '_' . $counter++;
  191. }
  192. $files[$name] = $fileInfo->getPathname();
  193. }
  194. }
  195. }
  196. return array_unique( $files );
  197. }
  198. public function getRecorder() {
  199. return $this->recorder;
  200. }
  201. /**
  202. * Do any setup which can be done once for all tests, independent of test
  203. * options, except for database setup.
  204. *
  205. * Public setup functions in this class return a ScopedCallback object. When
  206. * this object is destroyed by going out of scope, teardown of the
  207. * corresponding test setup is performed.
  208. *
  209. * Teardown objects may be chained by passing a ScopedCallback from a
  210. * previous setup stage as the $nextTeardown parameter. This enforces the
  211. * convention that teardown actions are taken in reverse order to the
  212. * corresponding setup actions. When $nextTeardown is specified, a
  213. * ScopedCallback will be returned which first tears down the current
  214. * setup stage, and then tears down the previous setup stage which was
  215. * specified by $nextTeardown.
  216. *
  217. * @param ScopedCallback|null $nextTeardown
  218. * @return ScopedCallback
  219. */
  220. public function staticSetup( $nextTeardown = null ) {
  221. // A note on coding style:
  222. // The general idea here is to keep setup code together with
  223. // corresponding teardown code, in a fine-grained manner. We have two
  224. // arrays: $setup and $teardown. The code snippets in the $setup array
  225. // are executed at the end of the method, before it returns, and the
  226. // code snippets in the $teardown array are executed in reverse order
  227. // when the Wikimedia\ScopedCallback object is consumed.
  228. // Because it is a common operation to save, set and restore global
  229. // variables, we have an additional convention: when the array key of
  230. // $setup is a string, the string is taken to be the name of the global
  231. // variable, and the element value is taken to be the desired new value.
  232. // It's acceptable to just do the setup immediately, instead of adding
  233. // a closure to $setup, except when the setup action depends on global
  234. // variable initialisation being done first. In this case, you have to
  235. // append a closure to $setup after the global variable is appended.
  236. // When you add to setup functions in this class, please keep associated
  237. // setup and teardown actions together in the source code, and please
  238. // add comments explaining why the setup action is necessary.
  239. $setup = [];
  240. $teardown = [];
  241. $teardown[] = $this->markSetupDone( 'staticSetup' );
  242. // Some settings which influence HTML output
  243. $setup['wgSitename'] = 'MediaWiki';
  244. $setup['wgServer'] = 'http://example.org';
  245. $setup['wgServerName'] = 'example.org';
  246. $setup['wgScriptPath'] = '';
  247. $setup['wgScript'] = '/index.php';
  248. $setup['wgResourceBasePath'] = '';
  249. $setup['wgStylePath'] = '/skins';
  250. $setup['wgExtensionAssetsPath'] = '/extensions';
  251. $setup['wgArticlePath'] = '/wiki/$1';
  252. $setup['wgActionPaths'] = [];
  253. $setup['wgVariantArticlePath'] = false;
  254. $setup['wgUploadNavigationUrl'] = false;
  255. $setup['wgCapitalLinks'] = true;
  256. $setup['wgNoFollowLinks'] = true;
  257. $setup['wgNoFollowDomainExceptions'] = [ 'no-nofollow.org' ];
  258. $setup['wgExternalLinkTarget'] = false;
  259. $setup['wgLocaltimezone'] = 'UTC';
  260. $setup['wgHtml5'] = true;
  261. $setup['wgDisableLangConversion'] = false;
  262. $setup['wgDisableTitleConversion'] = false;
  263. // "extra language links"
  264. // see https://gerrit.wikimedia.org/r/111390
  265. $setup['wgExtraInterlanguageLinkPrefixes'] = [ 'mul' ];
  266. // All FileRepo changes should be done here by injecting services,
  267. // there should be no need to change global variables.
  268. RepoGroup::setSingleton( $this->createRepoGroup() );
  269. $teardown[] = function () {
  270. RepoGroup::destroySingleton();
  271. };
  272. // Set up null lock managers
  273. $setup['wgLockManagers'] = [ [
  274. 'name' => 'fsLockManager',
  275. 'class' => NullLockManager::class,
  276. ], [
  277. 'name' => 'nullLockManager',
  278. 'class' => NullLockManager::class,
  279. ] ];
  280. $reset = function () {
  281. LockManagerGroup::destroySingletons();
  282. };
  283. $setup[] = $reset;
  284. $teardown[] = $reset;
  285. // This allows article insertion into the prefixed DB
  286. $setup['wgDefaultExternalStore'] = false;
  287. // This might slightly reduce memory usage
  288. $setup['wgAdaptiveMessageCache'] = true;
  289. // This is essential and overrides disabling of database messages in TestSetup
  290. $setup['wgUseDatabaseMessages'] = true;
  291. $reset = function () {
  292. MessageCache::destroyInstance();
  293. };
  294. $setup[] = $reset;
  295. $teardown[] = $reset;
  296. // It's not necessary to actually convert any files
  297. $setup['wgSVGConverter'] = 'null';
  298. $setup['wgSVGConverters'] = [ 'null' => 'echo "1">$output' ];
  299. // Fake constant timestamp
  300. Hooks::register( 'ParserGetVariableValueTs', function ( &$parser, &$ts ) {
  301. $ts = $this->getFakeTimestamp();
  302. return true;
  303. } );
  304. $teardown[] = function () {
  305. Hooks::clear( 'ParserGetVariableValueTs' );
  306. };
  307. $this->appendNamespaceSetup( $setup, $teardown );
  308. // Set up interwikis and append teardown function
  309. $teardown[] = $this->setupInterwikis();
  310. // This affects title normalization in links. It invalidates
  311. // MediaWikiTitleCodec objects.
  312. $setup['wgLocalInterwikis'] = [ 'local', 'mi' ];
  313. $reset = function () {
  314. $this->resetTitleServices();
  315. };
  316. $setup[] = $reset;
  317. $teardown[] = $reset;
  318. // Set up a mock MediaHandlerFactory
  319. MediaWikiServices::getInstance()->disableService( 'MediaHandlerFactory' );
  320. MediaWikiServices::getInstance()->redefineService(
  321. 'MediaHandlerFactory',
  322. function ( MediaWikiServices $services ) {
  323. $handlers = $services->getMainConfig()->get( 'ParserTestMediaHandlers' );
  324. return new MediaHandlerFactory( $handlers );
  325. }
  326. );
  327. $teardown[] = function () {
  328. MediaWikiServices::getInstance()->resetServiceForTesting( 'MediaHandlerFactory' );
  329. };
  330. // SqlBagOStuff broke when using temporary tables on r40209 (T17892).
  331. // It seems to have been fixed since (r55079?), but regressed at some point before r85701.
  332. // This works around it for now...
  333. global $wgObjectCaches;
  334. $setup['wgObjectCaches'] = [ CACHE_DB => $wgObjectCaches['hash'] ] + $wgObjectCaches;
  335. if ( isset( ObjectCache::$instances[CACHE_DB] ) ) {
  336. $savedCache = ObjectCache::$instances[CACHE_DB];
  337. ObjectCache::$instances[CACHE_DB] = new HashBagOStuff;
  338. $teardown[] = function () use ( $savedCache ) {
  339. ObjectCache::$instances[CACHE_DB] = $savedCache;
  340. };
  341. }
  342. $teardown[] = $this->executeSetupSnippets( $setup );
  343. // Schedule teardown snippets in reverse order
  344. return $this->createTeardownObject( $teardown, $nextTeardown );
  345. }
  346. private function appendNamespaceSetup( &$setup, &$teardown ) {
  347. // Add a namespace shadowing a interwiki link, to test
  348. // proper precedence when resolving links. (T53680)
  349. $setup['wgExtraNamespaces'] = [
  350. 100 => 'MemoryAlpha',
  351. 101 => 'MemoryAlpha_talk'
  352. ];
  353. // Changing wgExtraNamespaces invalidates caches in MWNamespace and
  354. // any live Language object, both on setup and teardown
  355. $reset = function () {
  356. MWNamespace::clearCaches();
  357. MediaWikiServices::getInstance()->getContentLanguage()->resetNamespaces();
  358. };
  359. $setup[] = $reset;
  360. $teardown[] = $reset;
  361. }
  362. /**
  363. * Create a RepoGroup object appropriate for the current configuration
  364. * @return RepoGroup
  365. */
  366. protected function createRepoGroup() {
  367. if ( $this->uploadDir ) {
  368. if ( $this->fileBackendName ) {
  369. throw new MWException( 'You cannot specify both use-filebackend and upload-dir' );
  370. }
  371. $backend = new FSFileBackend( [
  372. 'name' => 'local-backend',
  373. 'wikiId' => wfWikiID(),
  374. 'basePath' => $this->uploadDir,
  375. 'tmpDirectory' => wfTempDir()
  376. ] );
  377. } elseif ( $this->fileBackendName ) {
  378. global $wgFileBackends;
  379. $name = $this->fileBackendName;
  380. $useConfig = false;
  381. foreach ( $wgFileBackends as $conf ) {
  382. if ( $conf['name'] === $name ) {
  383. $useConfig = $conf;
  384. }
  385. }
  386. if ( $useConfig === false ) {
  387. throw new MWException( "Unable to find file backend \"$name\"" );
  388. }
  389. $useConfig['name'] = 'local-backend'; // swap name
  390. unset( $useConfig['lockManager'] );
  391. unset( $useConfig['fileJournal'] );
  392. $class = $useConfig['class'];
  393. $backend = new $class( $useConfig );
  394. } else {
  395. # Replace with a mock. We do not care about generating real
  396. # files on the filesystem, just need to expose the file
  397. # informations.
  398. $backend = new MockFileBackend( [
  399. 'name' => 'local-backend',
  400. 'wikiId' => wfWikiID()
  401. ] );
  402. }
  403. return new RepoGroup(
  404. [
  405. 'class' => MockLocalRepo::class,
  406. 'name' => 'local',
  407. 'url' => 'http://example.com/images',
  408. 'hashLevels' => 2,
  409. 'transformVia404' => false,
  410. 'backend' => $backend
  411. ],
  412. []
  413. );
  414. }
  415. /**
  416. * Execute an array in which elements with integer keys are taken to be
  417. * callable objects, and other elements are taken to be global variable
  418. * set operations, with the key giving the variable name and the value
  419. * giving the new global variable value. A closure is returned which, when
  420. * executed, sets the global variables back to the values they had before
  421. * this function was called.
  422. *
  423. * @see staticSetup
  424. *
  425. * @param array $setup
  426. * @return closure
  427. */
  428. protected function executeSetupSnippets( $setup ) {
  429. $saved = [];
  430. foreach ( $setup as $name => $value ) {
  431. if ( is_int( $name ) ) {
  432. $value();
  433. } else {
  434. $saved[$name] = $GLOBALS[$name] ?? null;
  435. $GLOBALS[$name] = $value;
  436. }
  437. }
  438. return function () use ( $saved ) {
  439. $this->executeSetupSnippets( $saved );
  440. };
  441. }
  442. /**
  443. * Take a setup array in the same format as the one given to
  444. * executeSetupSnippets(), and return a ScopedCallback which, when consumed,
  445. * executes the snippets in the setup array in reverse order. This is used
  446. * to create "teardown objects" for the public API.
  447. *
  448. * @see staticSetup
  449. *
  450. * @param array $teardown The snippet array
  451. * @param ScopedCallback|null $nextTeardown A ScopedCallback to consume
  452. * @return ScopedCallback
  453. */
  454. protected function createTeardownObject( $teardown, $nextTeardown = null ) {
  455. return new ScopedCallback( function () use ( $teardown, $nextTeardown ) {
  456. // Schedule teardown snippets in reverse order
  457. $teardown = array_reverse( $teardown );
  458. $this->executeSetupSnippets( $teardown );
  459. if ( $nextTeardown ) {
  460. ScopedCallback::consume( $nextTeardown );
  461. }
  462. } );
  463. }
  464. /**
  465. * Set a setupDone flag to indicate that setup has been done, and return
  466. * the teardown closure. If the flag was already set, throw an exception.
  467. *
  468. * @param string $funcName The setup function name
  469. * @return closure
  470. */
  471. protected function markSetupDone( $funcName ) {
  472. if ( $this->setupDone[$funcName] ) {
  473. throw new MWException( "$funcName is already done" );
  474. }
  475. $this->setupDone[$funcName] = true;
  476. return function () use ( $funcName ) {
  477. $this->setupDone[$funcName] = false;
  478. };
  479. }
  480. /**
  481. * Ensure a given setup stage has been done, throw an exception if it has
  482. * not.
  483. * @param string $funcName
  484. * @param string|null $funcName2
  485. */
  486. protected function checkSetupDone( $funcName, $funcName2 = null ) {
  487. if ( !$this->setupDone[$funcName]
  488. && ( $funcName === null || !$this->setupDone[$funcName2] )
  489. ) {
  490. throw new MWException( "$funcName must be called before calling " .
  491. wfGetCaller() );
  492. }
  493. }
  494. /**
  495. * Determine whether a particular setup function has been run
  496. *
  497. * @param string $funcName
  498. * @return bool
  499. */
  500. public function isSetupDone( $funcName ) {
  501. return $this->setupDone[$funcName] ?? false;
  502. }
  503. /**
  504. * Insert hardcoded interwiki in the lookup table.
  505. *
  506. * This function insert a set of well known interwikis that are used in
  507. * the parser tests. They can be considered has fixtures are injected in
  508. * the interwiki cache by using the 'InterwikiLoadPrefix' hook.
  509. * Since we are not interested in looking up interwikis in the database,
  510. * the hook completely replace the existing mechanism (hook returns false).
  511. *
  512. * @return closure for teardown
  513. */
  514. private function setupInterwikis() {
  515. # Hack: insert a few Wikipedia in-project interwiki prefixes,
  516. # for testing inter-language links
  517. Hooks::register( 'InterwikiLoadPrefix', function ( $prefix, &$iwData ) {
  518. static $testInterwikis = [
  519. 'local' => [
  520. 'iw_url' => 'http://doesnt.matter.org/$1',
  521. 'iw_api' => '',
  522. 'iw_wikiid' => '',
  523. 'iw_local' => 0 ],
  524. 'wikipedia' => [
  525. 'iw_url' => 'http://en.wikipedia.org/wiki/$1',
  526. 'iw_api' => '',
  527. 'iw_wikiid' => '',
  528. 'iw_local' => 0 ],
  529. 'meatball' => [
  530. 'iw_url' => 'http://www.usemod.com/cgi-bin/mb.pl?$1',
  531. 'iw_api' => '',
  532. 'iw_wikiid' => '',
  533. 'iw_local' => 0 ],
  534. 'memoryalpha' => [
  535. 'iw_url' => 'http://www.memory-alpha.org/en/index.php/$1',
  536. 'iw_api' => '',
  537. 'iw_wikiid' => '',
  538. 'iw_local' => 0 ],
  539. 'zh' => [
  540. 'iw_url' => 'http://zh.wikipedia.org/wiki/$1',
  541. 'iw_api' => '',
  542. 'iw_wikiid' => '',
  543. 'iw_local' => 1 ],
  544. 'es' => [
  545. 'iw_url' => 'http://es.wikipedia.org/wiki/$1',
  546. 'iw_api' => '',
  547. 'iw_wikiid' => '',
  548. 'iw_local' => 1 ],
  549. 'fr' => [
  550. 'iw_url' => 'http://fr.wikipedia.org/wiki/$1',
  551. 'iw_api' => '',
  552. 'iw_wikiid' => '',
  553. 'iw_local' => 1 ],
  554. 'ru' => [
  555. 'iw_url' => 'http://ru.wikipedia.org/wiki/$1',
  556. 'iw_api' => '',
  557. 'iw_wikiid' => '',
  558. 'iw_local' => 1 ],
  559. 'mi' => [
  560. 'iw_url' => 'http://mi.wikipedia.org/wiki/$1',
  561. 'iw_api' => '',
  562. 'iw_wikiid' => '',
  563. 'iw_local' => 1 ],
  564. 'mul' => [
  565. 'iw_url' => 'http://wikisource.org/wiki/$1',
  566. 'iw_api' => '',
  567. 'iw_wikiid' => '',
  568. 'iw_local' => 1 ],
  569. ];
  570. if ( array_key_exists( $prefix, $testInterwikis ) ) {
  571. $iwData = $testInterwikis[$prefix];
  572. }
  573. // We only want to rely on the above fixtures
  574. return false;
  575. } );// hooks::register
  576. // Reset the service in case any other tests already cached some prefixes.
  577. MediaWikiServices::getInstance()->resetServiceForTesting( 'InterwikiLookup' );
  578. return function () {
  579. // Tear down
  580. Hooks::clear( 'InterwikiLoadPrefix' );
  581. MediaWikiServices::getInstance()->resetServiceForTesting( 'InterwikiLookup' );
  582. };
  583. }
  584. /**
  585. * Reset the Title-related services that need resetting
  586. * for each test
  587. */
  588. private function resetTitleServices() {
  589. $services = MediaWikiServices::getInstance();
  590. $services->resetServiceForTesting( 'TitleFormatter' );
  591. $services->resetServiceForTesting( 'TitleParser' );
  592. $services->resetServiceForTesting( '_MediaWikiTitleCodec' );
  593. $services->resetServiceForTesting( 'LinkRenderer' );
  594. $services->resetServiceForTesting( 'LinkRendererFactory' );
  595. }
  596. /**
  597. * Remove last character if it is a newline
  598. * @param string $s
  599. * @return string
  600. */
  601. public static function chomp( $s ) {
  602. if ( substr( $s, -1 ) === "\n" ) {
  603. return substr( $s, 0, -1 );
  604. } else {
  605. return $s;
  606. }
  607. }
  608. /**
  609. * Run a series of tests listed in the given text files.
  610. * Each test consists of a brief description, wikitext input,
  611. * and the expected HTML output.
  612. *
  613. * Prints status updates on stdout and counts up the total
  614. * number and percentage of passed tests.
  615. *
  616. * Handles all setup and teardown.
  617. *
  618. * @param array $filenames Array of strings
  619. * @return bool True if passed all tests, false if any tests failed.
  620. */
  621. public function runTestsFromFiles( $filenames ) {
  622. $ok = false;
  623. $teardownGuard = $this->staticSetup();
  624. $teardownGuard = $this->setupDatabase( $teardownGuard );
  625. $teardownGuard = $this->setupUploads( $teardownGuard );
  626. $this->recorder->start();
  627. try {
  628. $ok = true;
  629. foreach ( $filenames as $filename ) {
  630. $testFileInfo = TestFileReader::read( $filename, [
  631. 'runDisabled' => $this->runDisabled,
  632. 'runParsoid' => $this->runParsoid,
  633. 'regex' => $this->regex ] );
  634. // Don't start the suite if there are no enabled tests in the file
  635. if ( !$testFileInfo['tests'] ) {
  636. continue;
  637. }
  638. $this->recorder->startSuite( $filename );
  639. $ok = $this->runTests( $testFileInfo ) && $ok;
  640. $this->recorder->endSuite( $filename );
  641. }
  642. $this->recorder->report();
  643. } catch ( DBError $e ) {
  644. $this->recorder->warning( $e->getMessage() );
  645. }
  646. $this->recorder->end();
  647. ScopedCallback::consume( $teardownGuard );
  648. return $ok;
  649. }
  650. /**
  651. * Determine whether the current parser has the hooks registered in it
  652. * that are required by a file read by TestFileReader.
  653. * @param array $requirements
  654. * @return bool
  655. */
  656. public function meetsRequirements( $requirements ) {
  657. foreach ( $requirements as $requirement ) {
  658. switch ( $requirement['type'] ) {
  659. case 'hook':
  660. $ok = $this->requireHook( $requirement['name'] );
  661. break;
  662. case 'functionHook':
  663. $ok = $this->requireFunctionHook( $requirement['name'] );
  664. break;
  665. case 'transparentHook':
  666. $ok = $this->requireTransparentHook( $requirement['name'] );
  667. break;
  668. }
  669. if ( !$ok ) {
  670. return false;
  671. }
  672. }
  673. return true;
  674. }
  675. /**
  676. * Run the tests from a single file. staticSetup() and setupDatabase()
  677. * must have been called already.
  678. *
  679. * @param array $testFileInfo Parsed file info returned by TestFileReader
  680. * @return bool True if passed all tests, false if any tests failed.
  681. */
  682. public function runTests( $testFileInfo ) {
  683. $ok = true;
  684. $this->checkSetupDone( 'staticSetup' );
  685. // Don't add articles from the file if there are no enabled tests from the file
  686. if ( !$testFileInfo['tests'] ) {
  687. return true;
  688. }
  689. // If any requirements are not met, mark all tests from the file as skipped
  690. if ( !$this->meetsRequirements( $testFileInfo['requirements'] ) ) {
  691. foreach ( $testFileInfo['tests'] as $test ) {
  692. $this->recorder->startTest( $test );
  693. $this->recorder->skipped( $test, 'required extension not enabled' );
  694. }
  695. return true;
  696. }
  697. // Add articles
  698. $this->addArticles( $testFileInfo['articles'] );
  699. // Run tests
  700. foreach ( $testFileInfo['tests'] as $test ) {
  701. $this->recorder->startTest( $test );
  702. $result =
  703. $this->runTest( $test );
  704. if ( $result !== false ) {
  705. $ok = $ok && $result->isSuccess();
  706. $this->recorder->record( $test, $result );
  707. }
  708. }
  709. return $ok;
  710. }
  711. /**
  712. * Get a Parser object
  713. *
  714. * @param string|null $preprocessor
  715. * @return Parser
  716. */
  717. function getParser( $preprocessor = null ) {
  718. global $wgParserConf;
  719. $class = $wgParserConf['class'];
  720. $parser = new $class( [ 'preprocessorClass' => $preprocessor ] + $wgParserConf );
  721. ParserTestParserHook::setup( $parser );
  722. return $parser;
  723. }
  724. /**
  725. * Run a given wikitext input through a freshly-constructed wiki parser,
  726. * and compare the output against the expected results.
  727. * Prints status and explanatory messages to stdout.
  728. *
  729. * staticSetup() and setupWikiData() must be called before this function
  730. * is entered.
  731. *
  732. * @param array $test The test parameters:
  733. * - test: The test name
  734. * - desc: The subtest description
  735. * - input: Wikitext to try rendering
  736. * - options: Array of test options
  737. * - config: Overrides for global variables, one per line
  738. *
  739. * @return ParserTestResult|false false if skipped
  740. */
  741. public function runTest( $test ) {
  742. wfDebug( __METHOD__ . ": running {$test['desc']}" );
  743. $opts = $this->parseOptions( $test['options'] );
  744. $teardownGuard = $this->perTestSetup( $test );
  745. $context = RequestContext::getMain();
  746. $user = $context->getUser();
  747. $options = ParserOptions::newFromContext( $context );
  748. $options->setTimestamp( $this->getFakeTimestamp() );
  749. if ( isset( $opts['tidy'] ) ) {
  750. if ( !$this->tidySupport->isEnabled() ) {
  751. $this->recorder->skipped( $test, 'tidy extension is not installed' );
  752. return false;
  753. } else {
  754. $options->setTidy( true );
  755. }
  756. }
  757. if ( isset( $opts['title'] ) ) {
  758. $titleText = $opts['title'];
  759. } else {
  760. $titleText = 'Parser test';
  761. }
  762. if ( isset( $opts['maxincludesize'] ) ) {
  763. $options->setMaxIncludeSize( $opts['maxincludesize'] );
  764. }
  765. if ( isset( $opts['maxtemplatedepth'] ) ) {
  766. $options->setMaxTemplateDepth( $opts['maxtemplatedepth'] );
  767. }
  768. $local = isset( $opts['local'] );
  769. $preprocessor = $opts['preprocessor'] ?? null;
  770. $parser = $this->getParser( $preprocessor );
  771. $title = Title::newFromText( $titleText );
  772. if ( isset( $opts['styletag'] ) ) {
  773. // For testing the behavior of <style> (including those deduplicated
  774. // into <link> tags), add tag hooks to allow them to be generated.
  775. $parser->setHook( 'style', function ( $content, $attributes, $parser ) {
  776. $marker = Parser::MARKER_PREFIX . '-style-' . md5( $content ) . Parser::MARKER_SUFFIX;
  777. $parser->mStripState->addNoWiki( $marker, $content );
  778. return Html::inlineStyle( $marker, 'all', $attributes );
  779. } );
  780. $parser->setHook( 'link', function ( $content, $attributes, $parser ) {
  781. return Html::element( 'link', $attributes );
  782. } );
  783. }
  784. if ( isset( $opts['pst'] ) ) {
  785. $out = $parser->preSaveTransform( $test['input'], $title, $user, $options );
  786. $output = $parser->getOutput();
  787. } elseif ( isset( $opts['msg'] ) ) {
  788. $out = $parser->transformMsg( $test['input'], $options, $title );
  789. } elseif ( isset( $opts['section'] ) ) {
  790. $section = $opts['section'];
  791. $out = $parser->getSection( $test['input'], $section );
  792. } elseif ( isset( $opts['replace'] ) ) {
  793. $section = $opts['replace'][0];
  794. $replace = $opts['replace'][1];
  795. $out = $parser->replaceSection( $test['input'], $section, $replace );
  796. } elseif ( isset( $opts['comment'] ) ) {
  797. $out = Linker::formatComment( $test['input'], $title, $local );
  798. } elseif ( isset( $opts['preload'] ) ) {
  799. $out = $parser->getPreloadText( $test['input'], $title, $options );
  800. } else {
  801. $output = $parser->parse( $test['input'], $title, $options, true, true, 1337 );
  802. $out = $output->getText( [
  803. 'allowTOC' => !isset( $opts['notoc'] ),
  804. 'unwrap' => !isset( $opts['wrap'] ),
  805. ] );
  806. if ( isset( $opts['tidy'] ) ) {
  807. $out = preg_replace( '/\s+$/', '', $out );
  808. }
  809. if ( isset( $opts['showtitle'] ) ) {
  810. if ( $output->getTitleText() ) {
  811. $title = $output->getTitleText();
  812. }
  813. $out = "$title\n$out";
  814. }
  815. if ( isset( $opts['showindicators'] ) ) {
  816. $indicators = '';
  817. foreach ( $output->getIndicators() as $id => $content ) {
  818. $indicators .= "$id=$content\n";
  819. }
  820. $out = $indicators . $out;
  821. }
  822. if ( isset( $opts['ill'] ) ) {
  823. $out = implode( ' ', $output->getLanguageLinks() );
  824. } elseif ( isset( $opts['cat'] ) ) {
  825. $out = '';
  826. foreach ( $output->getCategories() as $name => $sortkey ) {
  827. if ( $out !== '' ) {
  828. $out .= "\n";
  829. }
  830. $out .= "cat=$name sort=$sortkey";
  831. }
  832. }
  833. }
  834. if ( isset( $output ) && isset( $opts['showflags'] ) ) {
  835. $actualFlags = array_keys( TestingAccessWrapper::newFromObject( $output )->mFlags );
  836. sort( $actualFlags );
  837. $out .= "\nflags=" . implode( ', ', $actualFlags );
  838. }
  839. ScopedCallback::consume( $teardownGuard );
  840. $expected = $test['result'];
  841. if ( count( $this->normalizationFunctions ) ) {
  842. $expected = ParserTestResultNormalizer::normalize(
  843. $test['expected'], $this->normalizationFunctions );
  844. $out = ParserTestResultNormalizer::normalize( $out, $this->normalizationFunctions );
  845. }
  846. $testResult = new ParserTestResult( $test, $expected, $out );
  847. return $testResult;
  848. }
  849. /**
  850. * Use a regex to find out the value of an option
  851. * @param string $key Name of option val to retrieve
  852. * @param array $opts Options array to look in
  853. * @param mixed $default Default value returned if not found
  854. * @return mixed
  855. */
  856. private static function getOptionValue( $key, $opts, $default ) {
  857. $key = strtolower( $key );
  858. if ( isset( $opts[$key] ) ) {
  859. return $opts[$key];
  860. } else {
  861. return $default;
  862. }
  863. }
  864. /**
  865. * Given the options string, return an associative array of options.
  866. * @todo Move this to TestFileReader
  867. *
  868. * @param string $instring
  869. * @return array
  870. */
  871. private function parseOptions( $instring ) {
  872. $opts = [];
  873. // foo
  874. // foo=bar
  875. // foo="bar baz"
  876. // foo=[[bar baz]]
  877. // foo=bar,"baz quux"
  878. // foo={...json...}
  879. $defs = '(?(DEFINE)
  880. (?<qstr> # Quoted string
  881. "
  882. (?:[^\\\\"] | \\\\.)*
  883. "
  884. )
  885. (?<json>
  886. \{ # Open bracket
  887. (?:
  888. [^"{}] | # Not a quoted string or object, or
  889. (?&qstr) | # A quoted string, or
  890. (?&json) # A json object (recursively)
  891. )*
  892. \} # Close bracket
  893. )
  894. (?<value>
  895. (?:
  896. (?&qstr) # Quoted val
  897. |
  898. \[\[
  899. [^]]* # Link target
  900. \]\]
  901. |
  902. [\w-]+ # Plain word
  903. |
  904. (?&json) # JSON object
  905. )
  906. )
  907. )';
  908. $regex = '/' . $defs . '\b
  909. (?<k>[\w-]+) # Key
  910. \b
  911. (?:\s*
  912. = # First sub-value
  913. \s*
  914. (?<v>
  915. (?&value)
  916. (?:\s*
  917. , # Sub-vals 1..N
  918. \s*
  919. (?&value)
  920. )*
  921. )
  922. )?
  923. /x';
  924. $valueregex = '/' . $defs . '(?&value)/x';
  925. if ( preg_match_all( $regex, $instring, $matches, PREG_SET_ORDER ) ) {
  926. foreach ( $matches as $bits ) {
  927. $key = strtolower( $bits['k'] );
  928. if ( !isset( $bits['v'] ) ) {
  929. $opts[$key] = true;
  930. } else {
  931. preg_match_all( $valueregex, $bits['v'], $vmatches );
  932. $opts[$key] = array_map( [ $this, 'cleanupOption' ], $vmatches[0] );
  933. if ( count( $opts[$key] ) == 1 ) {
  934. $opts[$key] = $opts[$key][0];
  935. }
  936. }
  937. }
  938. }
  939. return $opts;
  940. }
  941. private function cleanupOption( $opt ) {
  942. if ( substr( $opt, 0, 1 ) == '"' ) {
  943. return stripcslashes( substr( $opt, 1, -1 ) );
  944. }
  945. if ( substr( $opt, 0, 2 ) == '[[' ) {
  946. return substr( $opt, 2, -2 );
  947. }
  948. if ( substr( $opt, 0, 1 ) == '{' ) {
  949. return FormatJson::decode( $opt, true );
  950. }
  951. return $opt;
  952. }
  953. /**
  954. * Do any required setup which is dependent on test options.
  955. *
  956. * @see staticSetup() for more information about setup/teardown
  957. *
  958. * @param array $test Test info supplied by TestFileReader
  959. * @param callable|null $nextTeardown
  960. * @return ScopedCallback
  961. */
  962. public function perTestSetup( $test, $nextTeardown = null ) {
  963. $teardown = [];
  964. $this->checkSetupDone( 'setupDatabase', 'setDatabase' );
  965. $teardown[] = $this->markSetupDone( 'perTestSetup' );
  966. $opts = $this->parseOptions( $test['options'] );
  967. $config = $test['config'];
  968. // Find out values for some special options.
  969. $langCode =
  970. self::getOptionValue( 'language', $opts, 'en' );
  971. $variant =
  972. self::getOptionValue( 'variant', $opts, false );
  973. $maxtoclevel =
  974. self::getOptionValue( 'wgMaxTocLevel', $opts, 999 );
  975. $linkHolderBatchSize =
  976. self::getOptionValue( 'wgLinkHolderBatchSize', $opts, 1000 );
  977. // Default to fallback skin, but allow it to be overridden
  978. $skin = self::getOptionValue( 'skin', $opts, 'fallback' );
  979. $setup = [
  980. 'wgEnableUploads' => self::getOptionValue( 'wgEnableUploads', $opts, true ),
  981. 'wgLanguageCode' => $langCode,
  982. 'wgRawHtml' => self::getOptionValue( 'wgRawHtml', $opts, false ),
  983. 'wgNamespacesWithSubpages' => array_fill_keys(
  984. MWNamespace::getValidNamespaces(), isset( $opts['subpage'] )
  985. ),
  986. 'wgMaxTocLevel' => $maxtoclevel,
  987. 'wgAllowExternalImages' => self::getOptionValue( 'wgAllowExternalImages', $opts, true ),
  988. 'wgThumbLimits' => [ self::getOptionValue( 'thumbsize', $opts, 180 ) ],
  989. 'wgDefaultLanguageVariant' => $variant,
  990. 'wgLinkHolderBatchSize' => $linkHolderBatchSize,
  991. // Set as a JSON object like:
  992. // wgEnableMagicLinks={"ISBN":false, "PMID":false, "RFC":false}
  993. 'wgEnableMagicLinks' => self::getOptionValue( 'wgEnableMagicLinks', $opts, [] )
  994. + [ 'ISBN' => true, 'PMID' => true, 'RFC' => true ],
  995. // Test with legacy encoding by default until HTML5 is very stable and default
  996. 'wgFragmentMode' => [ 'legacy' ],
  997. ];
  998. $nonIncludable = self::getOptionValue( 'wgNonincludableNamespaces', $opts, false );
  999. if ( $nonIncludable !== false ) {
  1000. $setup['wgNonincludableNamespaces'] = [ $nonIncludable ];
  1001. }
  1002. if ( $config ) {
  1003. $configLines = explode( "\n", $config );
  1004. foreach ( $configLines as $line ) {
  1005. list( $var, $value ) = explode( '=', $line, 2 );
  1006. $setup[$var] = eval( "return $value;" );
  1007. }
  1008. }
  1009. /** @since 1.20 */
  1010. Hooks::run( 'ParserTestGlobals', [ &$setup ] );
  1011. // Create tidy driver
  1012. if ( isset( $opts['tidy'] ) ) {
  1013. // Cache a driver instance
  1014. if ( $this->tidyDriver === null ) {
  1015. $this->tidyDriver = MWTidy::factory( $this->tidySupport->getConfig() );
  1016. }
  1017. $tidy = $this->tidyDriver;
  1018. } else {
  1019. $tidy = false;
  1020. }
  1021. MWTidy::setInstance( $tidy );
  1022. $teardown[] = function () {
  1023. MWTidy::destroySingleton();
  1024. };
  1025. // Set content language. This invalidates the magic word cache and title services
  1026. $lang = Language::factory( $langCode );
  1027. $lang->resetNamespaces();
  1028. $setup['wgContLang'] = $lang;
  1029. $setup[] = function () use ( $lang ) {
  1030. MediaWikiServices::getInstance()->disableService( 'ContentLanguage' );
  1031. MediaWikiServices::getInstance()->redefineService(
  1032. 'ContentLanguage',
  1033. function () use ( $lang ) {
  1034. return $lang;
  1035. }
  1036. );
  1037. };
  1038. $teardown[] = function () {
  1039. MediaWikiServices::getInstance()->resetServiceForTesting( 'ContentLanguage' );
  1040. };
  1041. $reset = function () {
  1042. MediaWikiServices::getInstance()->resetServiceForTesting( 'MagicWordFactory' );
  1043. $this->resetTitleServices();
  1044. };
  1045. $setup[] = $reset;
  1046. $teardown[] = $reset;
  1047. // Make a user object with the same language
  1048. $user = new User;
  1049. $user->setOption( 'language', $langCode );
  1050. $setup['wgLang'] = $lang;
  1051. // We (re)set $wgThumbLimits to a single-element array above.
  1052. $user->setOption( 'thumbsize', 0 );
  1053. $setup['wgUser'] = $user;
  1054. // And put both user and language into the context
  1055. $context = RequestContext::getMain();
  1056. $context->setUser( $user );
  1057. $context->setLanguage( $lang );
  1058. // And the skin!
  1059. $oldSkin = $context->getSkin();
  1060. $skinFactory = MediaWikiServices::getInstance()->getSkinFactory();
  1061. $context->setSkin( $skinFactory->makeSkin( $skin ) );
  1062. $context->setOutput( new OutputPage( $context ) );
  1063. $setup['wgOut'] = $context->getOutput();
  1064. $teardown[] = function () use ( $context, $oldSkin ) {
  1065. // Clear language conversion tables
  1066. $wrapper = TestingAccessWrapper::newFromObject(
  1067. $context->getLanguage()->getConverter()
  1068. );
  1069. $wrapper->reloadTables();
  1070. // Reset context to the restored globals
  1071. $context->setUser( $GLOBALS['wgUser'] );
  1072. $context->setLanguage( $GLOBALS['wgContLang'] );
  1073. $context->setSkin( $oldSkin );
  1074. $context->setOutput( $GLOBALS['wgOut'] );
  1075. };
  1076. $teardown[] = $this->executeSetupSnippets( $setup );
  1077. return $this->createTeardownObject( $teardown, $nextTeardown );
  1078. }
  1079. /**
  1080. * List of temporary tables to create, without prefix.
  1081. * Some of these probably aren't necessary.
  1082. * @return array
  1083. */
  1084. private function listTables() {
  1085. global $wgCommentTableSchemaMigrationStage, $wgActorTableSchemaMigrationStage;
  1086. $tables = [ 'user', 'user_properties', 'user_former_groups', 'page', 'page_restrictions',
  1087. 'protected_titles', 'revision', 'ip_changes', 'text', 'pagelinks', 'imagelinks',
  1088. 'categorylinks', 'templatelinks', 'externallinks', 'langlinks', 'iwlinks',
  1089. 'site_stats', 'ipblocks', 'image', 'oldimage',
  1090. 'recentchanges', 'watchlist', 'interwiki', 'logging', 'log_search',
  1091. 'querycache', 'objectcache', 'job', 'l10n_cache', 'redirect', 'querycachetwo',
  1092. 'archive', 'user_groups', 'page_props', 'category',
  1093. 'slots', 'content', 'slot_roles', 'content_models',
  1094. ];
  1095. if ( $wgCommentTableSchemaMigrationStage >= MIGRATION_WRITE_BOTH ) {
  1096. // The new tables for comments are in use
  1097. $tables[] = 'comment';
  1098. $tables[] = 'revision_comment_temp';
  1099. $tables[] = 'image_comment_temp';
  1100. }
  1101. if ( $wgActorTableSchemaMigrationStage >= MIGRATION_WRITE_BOTH ) {
  1102. // The new tables for actors are in use
  1103. $tables[] = 'actor';
  1104. $tables[] = 'revision_actor_temp';
  1105. }
  1106. if ( in_array( $this->db->getType(), [ 'mysql', 'sqlite', 'oracle' ] ) ) {
  1107. array_push( $tables, 'searchindex' );
  1108. }
  1109. // Allow extensions to add to the list of tables to duplicate;
  1110. // may be necessary if they hook into page save or other code
  1111. // which will require them while running tests.
  1112. Hooks::run( 'ParserTestTables', [ &$tables ] );
  1113. return $tables;
  1114. }
  1115. public function setDatabase( IDatabase $db ) {
  1116. $this->db = $db;
  1117. $this->setupDone['setDatabase'] = true;
  1118. }
  1119. /**
  1120. * Set up temporary DB tables.
  1121. *
  1122. * For best performance, call this once only for all tests. However, it can
  1123. * be called at the start of each test if more isolation is desired.
  1124. *
  1125. * @todo This is basically an unrefactored copy of
  1126. * MediaWikiTestCase::setupAllTestDBs. They should be factored out somehow.
  1127. *
  1128. * Do not call this function from a MediaWikiTestCase subclass, since
  1129. * MediaWikiTestCase does its own DB setup. Instead use setDatabase().
  1130. *
  1131. * @see staticSetup() for more information about setup/teardown
  1132. *
  1133. * @param ScopedCallback|null $nextTeardown The next teardown object
  1134. * @return ScopedCallback The teardown object
  1135. */
  1136. public function setupDatabase( $nextTeardown = null ) {
  1137. global $wgDBprefix;
  1138. $this->db = wfGetDB( DB_MASTER );
  1139. $dbType = $this->db->getType();
  1140. if ( $dbType == 'oracle' ) {
  1141. $suspiciousPrefixes = [ 'pt_', MediaWikiTestCase::ORA_DB_PREFIX ];
  1142. } else {
  1143. $suspiciousPrefixes = [ 'parsertest_', MediaWikiTestCase::DB_PREFIX ];
  1144. }
  1145. if ( in_array( $wgDBprefix, $suspiciousPrefixes ) ) {
  1146. throw new MWException( "\$wgDBprefix=$wgDBprefix suggests DB setup is already done" );
  1147. }
  1148. $teardown = [];
  1149. $teardown[] = $this->markSetupDone( 'setupDatabase' );
  1150. # CREATE TEMPORARY TABLE breaks if there is more than one server
  1151. if ( MediaWikiServices::getInstance()->getDBLoadBalancer()->getServerCount() != 1 ) {
  1152. $this->useTemporaryTables = false;
  1153. }
  1154. $temporary = $this->useTemporaryTables || $dbType == 'postgres';
  1155. $prefix = $dbType != 'oracle' ? 'parsertest_' : 'pt_';
  1156. $this->dbClone = new CloneDatabase( $this->db, $this->listTables(), $prefix );
  1157. $this->dbClone->useTemporaryTables( $temporary );
  1158. $this->dbClone->cloneTableStructure();
  1159. CloneDatabase::changePrefix( $prefix );
  1160. if ( $dbType == 'oracle' ) {
  1161. $this->db->query( 'BEGIN FILL_WIKI_INFO; END;' );
  1162. # Insert 0 user to prevent FK violations
  1163. # Anonymous user
  1164. $this->db->insert( 'user', [
  1165. 'user_id' => 0,
  1166. 'user_name' => 'Anonymous' ] );
  1167. }
  1168. $teardown[] = function () {
  1169. $this->teardownDatabase();
  1170. };
  1171. // Wipe some DB query result caches on setup and teardown
  1172. $reset = function () {
  1173. MediaWikiServices::getInstance()->getLinkCache()->clear();
  1174. // Clear the message cache
  1175. MessageCache::singleton()->clear();
  1176. };
  1177. $reset();
  1178. $teardown[] = $reset;
  1179. return $this->createTeardownObject( $teardown, $nextTeardown );
  1180. }
  1181. /**
  1182. * Add data about uploads to the new test DB, and set up the upload
  1183. * directory. This should be called after either setDatabase() or
  1184. * setupDatabase().
  1185. *
  1186. * @param ScopedCallback|null $nextTeardown The next teardown object
  1187. * @return ScopedCallback The teardown object
  1188. */
  1189. public function setupUploads( $nextTeardown = null ) {
  1190. $teardown = [];
  1191. $this->checkSetupDone( 'setupDatabase', 'setDatabase' );
  1192. $teardown[] = $this->markSetupDone( 'setupUploads' );
  1193. // Create the files in the upload directory (or pretend to create them
  1194. // in a MockFileBackend). Append teardown callback.
  1195. $teardown[] = $this->setupUploadBackend();
  1196. // Create a user
  1197. $user = User::createNew( 'WikiSysop' );
  1198. // Register the uploads in the database
  1199. $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Foobar.jpg' ) );
  1200. # note that the size/width/height/bits/etc of the file
  1201. # are actually set by inspecting the file itself; the arguments
  1202. # to recordUpload2 have no effect. That said, we try to make things
  1203. # match up so it is less confusing to readers of the code & tests.
  1204. $image->recordUpload2( '', 'Upload of some lame file', 'Some lame file', [
  1205. 'size' => 7881,
  1206. 'width' => 1941,
  1207. 'height' => 220,
  1208. 'bits' => 8,
  1209. 'media_type' => MEDIATYPE_BITMAP,
  1210. 'mime' => 'image/jpeg',
  1211. 'metadata' => serialize( [] ),
  1212. 'sha1' => Wikimedia\base_convert( '1', 16, 36, 31 ),
  1213. 'fileExists' => true
  1214. ], $this->db->timestamp( '20010115123500' ), $user );
  1215. $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Thumb.png' ) );
  1216. # again, note that size/width/height below are ignored; see above.
  1217. $image->recordUpload2( '', 'Upload of some lame thumbnail', 'Some lame thumbnail', [
  1218. 'size' => 22589,
  1219. 'width' => 135,
  1220. 'height' => 135,
  1221. 'bits' => 8,
  1222. 'media_type' => MEDIATYPE_BITMAP,
  1223. 'mime' => 'image/png',
  1224. 'metadata' => serialize( [] ),
  1225. 'sha1' => Wikimedia\base_convert( '2', 16, 36, 31 ),
  1226. 'fileExists' => true
  1227. ], $this->db->timestamp( '20130225203040' ), $user );
  1228. $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Foobar.svg' ) );
  1229. $image->recordUpload2( '', 'Upload of some lame SVG', 'Some lame SVG', [
  1230. 'size' => 12345,
  1231. 'width' => 240,
  1232. 'height' => 180,
  1233. 'bits' => 0,
  1234. 'media_type' => MEDIATYPE_DRAWING,
  1235. 'mime' => 'image/svg+xml',
  1236. 'metadata' => serialize( [] ),
  1237. 'sha1' => Wikimedia\base_convert( '', 16, 36, 31 ),
  1238. 'fileExists' => true
  1239. ], $this->db->timestamp( '20010115123500' ), $user );
  1240. # This image will be blacklisted in [[MediaWiki:Bad image list]]
  1241. $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Bad.jpg' ) );
  1242. $image->recordUpload2( '', 'zomgnotcensored', 'Borderline image', [
  1243. 'size' => 12345,
  1244. 'width' => 320,
  1245. 'height' => 240,
  1246. 'bits' => 24,
  1247. 'media_type' => MEDIATYPE_BITMAP,
  1248. 'mime' => 'image/jpeg',
  1249. 'metadata' => serialize( [] ),
  1250. 'sha1' => Wikimedia\base_convert( '3', 16, 36, 31 ),
  1251. 'fileExists' => true
  1252. ], $this->db->timestamp( '20010115123500' ), $user );
  1253. $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Video.ogv' ) );
  1254. $image->recordUpload2( '', 'A pretty movie', 'Will it play', [
  1255. 'size' => 12345,
  1256. 'width' => 320,
  1257. 'height' => 240,
  1258. 'bits' => 0,
  1259. 'media_type' => MEDIATYPE_VIDEO,
  1260. 'mime' => 'application/ogg',
  1261. 'metadata' => serialize( [] ),
  1262. 'sha1' => Wikimedia\base_convert( '', 16, 36, 31 ),
  1263. 'fileExists' => true
  1264. ], $this->db->timestamp( '20010115123500' ), $user );
  1265. $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Audio.oga' ) );
  1266. $image->recordUpload2( '', 'An awesome hitsong', 'Will it play', [
  1267. 'size' => 12345,
  1268. 'width' => 0,
  1269. 'height' => 0,
  1270. 'bits' => 0,
  1271. 'media_type' => MEDIATYPE_AUDIO,
  1272. 'mime' => 'application/ogg',
  1273. 'metadata' => serialize( [] ),
  1274. 'sha1' => Wikimedia\base_convert( '', 16, 36, 31 ),
  1275. 'fileExists' => true
  1276. ], $this->db->timestamp( '20010115123500' ), $user );
  1277. # A DjVu file
  1278. $image = wfLocalFile( Title::makeTitle( NS_FILE, 'LoremIpsum.djvu' ) );
  1279. $image->recordUpload2( '', 'Upload a DjVu', 'A DjVu', [
  1280. 'size' => 3249,
  1281. 'width' => 2480,
  1282. 'height' => 3508,
  1283. 'bits' => 0,
  1284. 'media_type' => MEDIATYPE_BITMAP,
  1285. 'mime' => 'image/vnd.djvu',
  1286. 'metadata' => '<?xml version="1.0" ?>
  1287. <!DOCTYPE DjVuXML PUBLIC "-//W3C//DTD DjVuXML 1.1//EN" "pubtext/DjVuXML-s.dtd">
  1288. <DjVuXML>
  1289. <HEAD></HEAD>
  1290. <BODY><OBJECT height="3508" width="2480">
  1291. <PARAM name="DPI" value="300" />
  1292. <PARAM name="GAMMA" value="2.2" />
  1293. </OBJECT>
  1294. <OBJECT height="3508" width="2480">
  1295. <PARAM name="DPI" value="300" />
  1296. <PARAM name="GAMMA" value="2.2" />
  1297. </OBJECT>
  1298. <OBJECT height="3508" width="2480">
  1299. <PARAM name="DPI" value="300" />
  1300. <PARAM name="GAMMA" value="2.2" />
  1301. </OBJECT>
  1302. <OBJECT height="3508" width="2480">
  1303. <PARAM name="DPI" value="300" />
  1304. <PARAM name="GAMMA" value="2.2" />
  1305. </OBJECT>
  1306. <OBJECT height="3508" width="2480">
  1307. <PARAM name="DPI" value="300" />
  1308. <PARAM name="GAMMA" value="2.2" />
  1309. </OBJECT>
  1310. </BODY>
  1311. </DjVuXML>',
  1312. 'sha1' => Wikimedia\base_convert( '', 16, 36, 31 ),
  1313. 'fileExists' => true
  1314. ], $this->db->timestamp( '20010115123600' ), $user );
  1315. return $this->createTeardownObject( $teardown, $nextTeardown );
  1316. }
  1317. /**
  1318. * Helper for database teardown, called from the teardown closure. Destroy
  1319. * the database clone and fix up some things that CloneDatabase doesn't fix.
  1320. *
  1321. * @todo Move most things here to CloneDatabase
  1322. */
  1323. private function teardownDatabase() {
  1324. $this->checkSetupDone( 'setupDatabase' );
  1325. $this->dbClone->destroy();
  1326. if ( $this->useTemporaryTables ) {
  1327. if ( $this->db->getType() == 'sqlite' ) {
  1328. # Under SQLite the searchindex table is virtual and need
  1329. # to be explicitly destroyed. See T31912
  1330. # See also MediaWikiTestCase::destroyDB()
  1331. wfDebug( __METHOD__ . " explicitly destroying sqlite virtual table parsertest_searchindex\n" );
  1332. $this->db->query( "DROP TABLE `parsertest_searchindex`" );
  1333. }
  1334. # Don't need to do anything
  1335. return;
  1336. }
  1337. $tables = $this->listTables();
  1338. foreach ( $tables as $table ) {
  1339. if ( $this->db->getType() == 'oracle' ) {
  1340. $this->db->query( "DROP TABLE pt_$table DROP CONSTRAINTS" );
  1341. } else {
  1342. $this->db->query( "DROP TABLE `parsertest_$table`" );
  1343. }
  1344. }
  1345. if ( $this->db->getType() == 'oracle' ) {
  1346. $this->db->query( 'BEGIN FILL_WIKI_INFO; END;' );
  1347. }
  1348. }
  1349. /**
  1350. * Upload test files to the backend created by createRepoGroup().
  1351. *
  1352. * @return callable The teardown callback
  1353. */
  1354. private function setupUploadBackend() {
  1355. global $IP;
  1356. $repo = RepoGroup::singleton()->getLocalRepo();
  1357. $base = $repo->getZonePath( 'public' );
  1358. $backend = $repo->getBackend();
  1359. $backend->prepare( [ 'dir' => "$base/3/3a" ] );
  1360. $backend->store( [
  1361. 'src' => "$IP/tests/phpunit/data/parser/headbg.jpg",
  1362. 'dst' => "$base/3/3a/Foobar.jpg"
  1363. ] );
  1364. $backend->prepare( [ 'dir' => "$base/e/ea" ] );
  1365. $backend->store( [
  1366. 'src' => "$IP/tests/phpunit/data/parser/wiki.png",
  1367. 'dst' => "$base/e/ea/Thumb.png"
  1368. ] );
  1369. $backend->prepare( [ 'dir' => "$base/0/09" ] );
  1370. $backend->store( [
  1371. 'src' => "$IP/tests/phpunit/data/parser/headbg.jpg",
  1372. 'dst' => "$base/0/09/Bad.jpg"
  1373. ] );
  1374. $backend->prepare( [ 'dir' => "$base/5/5f" ] );
  1375. $backend->store( [
  1376. 'src' => "$IP/tests/phpunit/data/parser/LoremIpsum.djvu",
  1377. 'dst' => "$base/5/5f/LoremIpsum.djvu"
  1378. ] );
  1379. // No helpful SVG file to copy, so make one ourselves
  1380. $data = '<?xml version="1.0" encoding="utf-8"?>' .
  1381. '<svg xmlns="http://www.w3.org/2000/svg"' .
  1382. ' version="1.1" width="240" height="180"/>';
  1383. $backend->prepare( [ 'dir' => "$base/f/ff" ] );
  1384. $backend->quickCreate( [
  1385. 'content' => $data, 'dst' => "$base/f/ff/Foobar.svg"
  1386. ] );
  1387. return function () use ( $backend ) {
  1388. if ( $backend instanceof MockFileBackend ) {
  1389. // In memory backend, so dont bother cleaning them up.
  1390. return;
  1391. }
  1392. $this->teardownUploadBackend();
  1393. };
  1394. }
  1395. /**
  1396. * Remove the dummy uploads directory
  1397. */
  1398. private function teardownUploadBackend() {
  1399. if ( $this->keepUploads ) {
  1400. return;
  1401. }
  1402. $repo = RepoGroup::singleton()->getLocalRepo();
  1403. $public = $repo->getZonePath( 'public' );
  1404. $this->deleteFiles(
  1405. [
  1406. "$public/3/3a/Foobar.jpg",
  1407. "$public/e/ea/Thumb.png",
  1408. "$public/0/09/Bad.jpg",
  1409. "$public/5/5f/LoremIpsum.djvu",
  1410. "$public/f/ff/Foobar.svg",
  1411. "$public/0/00/Video.ogv",
  1412. "$public/4/41/Audio.oga",
  1413. ]
  1414. );
  1415. }
  1416. /**
  1417. * Delete the specified files and their parent directories
  1418. * @param array $files File backend URIs mwstore://...
  1419. */
  1420. private function deleteFiles( $files ) {
  1421. // Delete the files
  1422. $backend = RepoGroup::singleton()->getLocalRepo()->getBackend();
  1423. foreach ( $files as $file ) {
  1424. $backend->delete( [ 'src' => $file ], [ 'force' => 1 ] );
  1425. }
  1426. // Delete the parent directories
  1427. foreach ( $files as $file ) {
  1428. $tmp = FileBackend::parentStoragePath( $file );
  1429. while ( $tmp ) {
  1430. if ( !$backend->clean( [ 'dir' => $tmp ] )->isOK() ) {
  1431. break;
  1432. }
  1433. $tmp = FileBackend::parentStoragePath( $tmp );
  1434. }
  1435. }
  1436. }
  1437. /**
  1438. * Add articles to the test DB.
  1439. *
  1440. * @param array $articles Article info array from TestFileReader
  1441. */
  1442. public function addArticles( $articles ) {
  1443. $setup = [];
  1444. $teardown = [];
  1445. // Be sure ParserTestRunner::addArticle has correct language set,
  1446. // so that system messages get into the right language cache
  1447. if ( MediaWikiServices::getInstance()->getContentLanguage()->getCode() !== 'en' ) {
  1448. $setup['wgLanguageCode'] = 'en';
  1449. $lang = Language::factory( 'en' );
  1450. $setup['wgContLang'] = $lang;
  1451. $setup[] = function () use ( $lang ) {
  1452. $services = MediaWikiServices::getInstance();
  1453. $services->disableService( 'ContentLanguage' );
  1454. $services->redefineService( 'ContentLanguage', function () use ( $lang ) {
  1455. return $lang;
  1456. } );
  1457. };
  1458. $teardown[] = function () {
  1459. MediaWikiServices::getInstance()->resetServiceForTesting( 'ContentLanguage' );
  1460. };
  1461. }
  1462. // Add special namespaces, in case that hasn't been done by staticSetup() yet
  1463. $this->appendNamespaceSetup( $setup, $teardown );
  1464. // wgCapitalLinks obviously needs initialisation
  1465. $setup['wgCapitalLinks'] = true;
  1466. $teardown[] = $this->executeSetupSnippets( $setup );
  1467. foreach ( $articles as $info ) {
  1468. $this->addArticle( $info['name'], $info['text'], $info['file'], $info['line'] );
  1469. }
  1470. // Wipe WANObjectCache process cache, which is invalidated by article insertion
  1471. // due to T144706
  1472. ObjectCache::getMainWANInstance()->clearProcessCache();
  1473. $this->executeSetupSnippets( $teardown );
  1474. }
  1475. /**
  1476. * Insert a temporary test article
  1477. * @param string $name The title, including any prefix
  1478. * @param string $text The article text
  1479. * @param string $file The input file name
  1480. * @param int|string $line The input line number, for reporting errors
  1481. * @throws Exception
  1482. * @throws MWException
  1483. */
  1484. private function addArticle( $name, $text, $file, $line ) {
  1485. $text = self::chomp( $text );
  1486. $name = self::chomp( $name );
  1487. $title = Title::newFromText( $name );
  1488. wfDebug( __METHOD__ . ": adding $name" );
  1489. if ( is_null( $title ) ) {
  1490. throw new MWException( "invalid title '$name' at $file:$line\n" );
  1491. }
  1492. $newContent = ContentHandler::makeContent( $text, $title );
  1493. $page = WikiPage::factory( $title );
  1494. $page->loadPageData( 'fromdbmaster' );
  1495. if ( $page->exists() ) {
  1496. $content = $page->getContent( Revision::RAW );
  1497. // Only reject the title, if the content/content model is different.
  1498. // This makes it easier to create Template:(( or Template:)) in different extensions
  1499. if ( $newContent->equals( $content ) ) {
  1500. return;
  1501. }
  1502. throw new MWException(
  1503. "duplicate article '$name' with different content at $file:$line\n"
  1504. );
  1505. }
  1506. // Optionally use mock parser, to make debugging of actual parser tests simpler.
  1507. // But initialise the MessageCache clone first, don't let MessageCache
  1508. // get a reference to the mock object.
  1509. if ( $this->disableSaveParse ) {
  1510. MessageCache::singleton()->getParser();
  1511. $restore = $this->executeSetupSnippets( [ 'wgParser' => new ParserTestMockParser ] );
  1512. } else {
  1513. $restore = false;
  1514. }
  1515. try {
  1516. $status = $page->doEditContent(
  1517. $newContent,
  1518. '',
  1519. EDIT_NEW | EDIT_INTERNAL
  1520. );
  1521. } finally {
  1522. if ( $restore ) {
  1523. $restore();
  1524. }
  1525. }
  1526. if ( !$status->isOK() ) {
  1527. throw new MWException( $status->getWikiText( false, false, 'en' ) );
  1528. }
  1529. // The RepoGroup cache is invalidated by the creation of file redirects
  1530. if ( $title->inNamespace( NS_FILE ) ) {
  1531. RepoGroup::singleton()->clearCache( $title );
  1532. }
  1533. }
  1534. /**
  1535. * Check if a hook is installed
  1536. *
  1537. * @param string $name
  1538. * @return bool True if tag hook is present
  1539. */
  1540. public function requireHook( $name ) {
  1541. global $wgParser;
  1542. $wgParser->firstCallInit(); // make sure hooks are loaded.
  1543. if ( isset( $wgParser->mTagHooks[$name] ) ) {
  1544. return true;
  1545. } else {
  1546. $this->recorder->warning( " This test suite requires the '$name' hook " .
  1547. "extension, skipping." );
  1548. return false;
  1549. }
  1550. }
  1551. /**
  1552. * Check if a function hook is installed
  1553. *
  1554. * @param string $name
  1555. * @return bool True if function hook is present
  1556. */
  1557. public function requireFunctionHook( $name ) {
  1558. global $wgParser;
  1559. $wgParser->firstCallInit(); // make sure hooks are loaded.
  1560. if ( isset( $wgParser->mFunctionHooks[$name] ) ) {
  1561. return true;
  1562. } else {
  1563. $this->recorder->warning( " This test suite requires the '$name' function " .
  1564. "hook extension, skipping." );
  1565. return false;
  1566. }
  1567. }
  1568. /**
  1569. * Check if a transparent tag hook is installed
  1570. *
  1571. * @param string $name
  1572. * @return bool True if function hook is present
  1573. */
  1574. public function requireTransparentHook( $name ) {
  1575. global $wgParser;
  1576. $wgParser->firstCallInit(); // make sure hooks are loaded.
  1577. if ( isset( $wgParser->mTransparentTagHooks[$name] ) ) {
  1578. return true;
  1579. } else {
  1580. $this->recorder->warning( " This test suite requires the '$name' transparent " .
  1581. "hook extension, skipping.\n" );
  1582. return false;
  1583. }
  1584. }
  1585. /**
  1586. * Fake constant timestamp to make sure time-related parser
  1587. * functions give a persistent value.
  1588. *
  1589. * - Parser::getVariableValue (via ParserGetVariableValueTs hook)
  1590. * - Parser::preSaveTransform (via ParserOptions)
  1591. */
  1592. private function getFakeTimestamp() {
  1593. // parsed as '1970-01-01T00:02:03Z'
  1594. return 123;
  1595. }
  1596. }