1<?php 2 3/** 4 * PHPUnit wrapper. 5 */ 6final class PhpunitTestEngine extends ArcanistUnitTestEngine { 7 8 private $configFile; 9 private $phpunitBinary = 'phpunit'; 10 private $affectedTests; 11 private $projectRoot; 12 13 public function run() { 14 $this->projectRoot = $this->getWorkingCopy()->getProjectRoot(); 15 $this->affectedTests = array(); 16 foreach ($this->getPaths() as $path) { 17 18 $path = Filesystem::resolvePath($path, $this->projectRoot); 19 20 // TODO: add support for directories 21 // Users can call phpunit on the directory themselves 22 if (is_dir($path)) { 23 continue; 24 } 25 26 // Not sure if it would make sense to go further if 27 // it is not a .php file 28 if (substr($path, -4) != '.php') { 29 continue; 30 } 31 32 if (substr($path, -8) == 'Test.php') { 33 // Looks like a valid test file name. 34 $this->affectedTests[$path] = $path; 35 continue; 36 } 37 38 if ($test = $this->findTestFile($path)) { 39 $this->affectedTests[$path] = $test; 40 } 41 42 } 43 44 if (empty($this->affectedTests)) { 45 throw new ArcanistNoEffectException(pht('No tests to run.')); 46 } 47 48 $this->prepareConfigFile(); 49 $futures = array(); 50 $tmpfiles = array(); 51 foreach ($this->affectedTests as $class_path => $test_path) { 52 if (!Filesystem::pathExists($test_path)) { 53 continue; 54 } 55 $json_tmp = new TempFile(); 56 $clover_tmp = null; 57 $clover = null; 58 if ($this->getEnableCoverage() !== false) { 59 $clover_tmp = new TempFile(); 60 $clover = csprintf('--coverage-clover %s', $clover_tmp); 61 } 62 63 $config = $this->configFile ? csprintf('-c %s', $this->configFile) : null; 64 65 $stderr = '-d display_errors=stderr'; 66 67 $futures[$test_path] = new ExecFuture('%C %C %C --log-json %s %C %s', 68 $this->phpunitBinary, $config, $stderr, $json_tmp, $clover, $test_path); 69 $tmpfiles[$test_path] = array( 70 'json' => $json_tmp, 71 'clover' => $clover_tmp, 72 ); 73 } 74 75 $results = array(); 76 $futures = id(new FutureIterator($futures)) 77 ->limit(4); 78 foreach ($futures as $test => $future) { 79 80 list($err, $stdout, $stderr) = $future->resolve(); 81 82 $results[] = $this->parseTestResults( 83 $test, 84 $tmpfiles[$test]['json'], 85 $tmpfiles[$test]['clover'], 86 $stderr); 87 } 88 89 return array_mergev($results); 90 } 91 92 /** 93 * Parse test results from phpunit json report. 94 * 95 * @param string $path Path to test 96 * @param string $json_tmp Path to phpunit json report 97 * @param string $clover_tmp Path to phpunit clover report 98 * @param string $stderr Data written to stderr 99 * 100 * @return array 101 */ 102 private function parseTestResults($path, $json_tmp, $clover_tmp, $stderr) { 103 $test_results = Filesystem::readFile($json_tmp); 104 return id(new ArcanistPhpunitTestResultParser()) 105 ->setEnableCoverage($this->getEnableCoverage()) 106 ->setProjectRoot($this->projectRoot) 107 ->setCoverageFile($clover_tmp) 108 ->setAffectedTests($this->affectedTests) 109 ->setStderr($stderr) 110 ->parseTestResults($path, $test_results); 111 } 112 113 114 /** 115 * Search for test cases for a given file in a large number of "reasonable" 116 * locations. See @{method:getSearchLocationsForTests} for specifics. 117 * 118 * TODO: Add support for finding tests in testsuite folders from 119 * phpunit.xml configuration. 120 * 121 * @param string PHP file to locate test cases for. 122 * @return string|null Path to test cases, or null. 123 */ 124 private function findTestFile($path) { 125 $root = $this->projectRoot; 126 $path = Filesystem::resolvePath($path, $root); 127 128 $file = basename($path); 129 $possible_files = array( 130 $file, 131 substr($file, 0, -4).'Test.php', 132 ); 133 134 $search = self::getSearchLocationsForTests($path); 135 136 foreach ($search as $search_path) { 137 foreach ($possible_files as $possible_file) { 138 $full_path = $search_path.$possible_file; 139 if (!Filesystem::pathExists($full_path)) { 140 // If the file doesn't exist, it's clearly a miss. 141 continue; 142 } 143 if (!Filesystem::isDescendant($full_path, $root)) { 144 // Don't look above the project root. 145 continue; 146 } 147 if (0 == strcasecmp(Filesystem::resolvePath($full_path), $path)) { 148 // Don't return the original file. 149 continue; 150 } 151 return $full_path; 152 } 153 } 154 155 return null; 156 } 157 158 159 /** 160 * Get places to look for PHP Unit tests that cover a given file. For some 161 * file "/a/b/c/X.php", we look in the same directory: 162 * 163 * /a/b/c/ 164 * 165 * We then look in all parent directories for a directory named "tests/" 166 * (or "Tests/"): 167 * 168 * /a/b/c/tests/ 169 * /a/b/tests/ 170 * /a/tests/ 171 * /tests/ 172 * 173 * We also try to replace each directory component with "tests/": 174 * 175 * /a/b/tests/ 176 * /a/tests/c/ 177 * /tests/b/c/ 178 * 179 * We also try to add "tests/" at each directory level: 180 * 181 * /a/b/c/tests/ 182 * /a/b/tests/c/ 183 * /a/tests/b/c/ 184 * /tests/a/b/c/ 185 * 186 * This finds tests with a layout like: 187 * 188 * docs/ 189 * src/ 190 * tests/ 191 * 192 * ...or similar. This list will be further pruned by the caller; it is 193 * intentionally filesystem-agnostic to be unit testable. 194 * 195 * @param string PHP file to locate test cases for. 196 * @return list<string> List of directories to search for tests in. 197 */ 198 public static function getSearchLocationsForTests($path) { 199 $file = basename($path); 200 $dir = dirname($path); 201 202 $test_dir_names = array('tests', 'Tests'); 203 204 $try_directories = array(); 205 206 // Try in the current directory. 207 $try_directories[] = array($dir); 208 209 // Try in a tests/ directory anywhere in the ancestry. 210 foreach (Filesystem::walkToRoot($dir) as $parent_dir) { 211 if ($parent_dir == '/') { 212 // We'll restore this later. 213 $parent_dir = ''; 214 } 215 foreach ($test_dir_names as $test_dir_name) { 216 $try_directories[] = array($parent_dir, $test_dir_name); 217 } 218 } 219 220 // Try replacing each directory component with 'tests/'. 221 $parts = trim($dir, DIRECTORY_SEPARATOR); 222 $parts = explode(DIRECTORY_SEPARATOR, $parts); 223 foreach (array_reverse(array_keys($parts)) as $key) { 224 foreach ($test_dir_names as $test_dir_name) { 225 $try = $parts; 226 $try[$key] = $test_dir_name; 227 array_unshift($try, ''); 228 $try_directories[] = $try; 229 } 230 } 231 232 // Try adding 'tests/' at each level. 233 foreach (array_reverse(array_keys($parts)) as $key) { 234 foreach ($test_dir_names as $test_dir_name) { 235 $try = $parts; 236 $try[$key] = $test_dir_name.DIRECTORY_SEPARATOR.$try[$key]; 237 array_unshift($try, ''); 238 $try_directories[] = $try; 239 } 240 } 241 242 $results = array(); 243 foreach ($try_directories as $parts) { 244 $results[implode(DIRECTORY_SEPARATOR, $parts).DIRECTORY_SEPARATOR] = true; 245 } 246 247 return array_keys($results); 248 } 249 250 /** 251 * Tries to find and update phpunit configuration file based on 252 * `phpunit_config` option in `.arcconfig`. 253 */ 254 private function prepareConfigFile() { 255 $project_root = $this->projectRoot.DIRECTORY_SEPARATOR; 256 $config = $this->getConfigurationManager()->getConfigFromAnySource( 257 'phpunit_config'); 258 259 if ($config) { 260 if (Filesystem::pathExists($project_root.$config)) { 261 $this->configFile = $project_root.$config; 262 } else { 263 throw new Exception( 264 pht( 265 'PHPUnit configuration file was not found in %s', 266 $project_root.$config)); 267 } 268 } 269 $bin = $this->getConfigurationManager()->getConfigFromAnySource( 270 'unit.phpunit.binary'); 271 if ($bin) { 272 if (Filesystem::binaryExists($bin)) { 273 $this->phpunitBinary = $bin; 274 } else { 275 $this->phpunitBinary = Filesystem::resolvePath($bin, $project_root); 276 } 277 } 278 } 279 280} 281