* 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