diff options
Diffstat (limited to 'www/wiki/tests/phpunit/structure')
8 files changed, 1436 insertions, 0 deletions
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; + } +} |