1<?php 2 3/** 4 * Uses xUnit (http://xunit.codeplex.com/) to test C# code. 5 * 6 * Assumes that when modifying a file with a path like `SomeAssembly/MyFile.cs`, 7 * that the test assembly that verifies the functionality of `SomeAssembly` is 8 * located at `SomeAssembly.Tests`. 9 * 10 * @concrete-extensible 11 */ 12class XUnitTestEngine extends ArcanistUnitTestEngine { 13 14 protected $runtimeEngine; 15 protected $buildEngine; 16 protected $testEngine; 17 protected $projectRoot; 18 protected $xunitHintPath; 19 protected $discoveryRules; 20 21 /** 22 * This test engine supports running all tests. 23 */ 24 protected function supportsRunAllTests() { 25 return true; 26 } 27 28 /** 29 * Determines what executables and test paths to use. Between platforms this 30 * also changes whether the test engine is run under .NET or Mono. It also 31 * ensures that all of the required binaries are available for the tests to 32 * run successfully. 33 * 34 * @return void 35 */ 36 protected function loadEnvironment() { 37 $this->projectRoot = $this->getWorkingCopy()->getProjectRoot(); 38 39 // Determine build engine. 40 if (Filesystem::binaryExists('msbuild')) { 41 $this->buildEngine = 'msbuild'; 42 } else if (Filesystem::binaryExists('xbuild')) { 43 $this->buildEngine = 'xbuild'; 44 } else { 45 throw new Exception( 46 pht( 47 'Unable to find %s or %s in %s!', 48 'msbuild', 49 'xbuild', 50 'PATH')); 51 } 52 53 // Determine runtime engine (.NET or Mono). 54 if (phutil_is_windows()) { 55 $this->runtimeEngine = ''; 56 } else if (Filesystem::binaryExists('mono')) { 57 $this->runtimeEngine = Filesystem::resolveBinary('mono'); 58 } else { 59 throw new Exception( 60 pht('Unable to find Mono and you are not on Windows!')); 61 } 62 63 // Read the discovery rules. 64 $this->discoveryRules = 65 $this->getConfigurationManager()->getConfigFromAnySource( 66 'unit.csharp.discovery'); 67 if ($this->discoveryRules === null) { 68 throw new Exception( 69 pht( 70 'You must configure discovery rules to map C# files '. 71 'back to test projects (`%s` in %s).', 72 'unit.csharp.discovery', 73 '.arcconfig')); 74 } 75 76 // Determine xUnit test runner path. 77 if ($this->xunitHintPath === null) { 78 $this->xunitHintPath = 79 $this->getConfigurationManager()->getConfigFromAnySource( 80 'unit.csharp.xunit.binary'); 81 } 82 $xunit = $this->projectRoot.DIRECTORY_SEPARATOR.$this->xunitHintPath; 83 if (file_exists($xunit) && $this->xunitHintPath !== null) { 84 $this->testEngine = Filesystem::resolvePath($xunit); 85 } else if (Filesystem::binaryExists('xunit.console.clr4.exe')) { 86 $this->testEngine = 'xunit.console.clr4.exe'; 87 } else { 88 throw new Exception( 89 pht( 90 "Unable to locate xUnit console runner. Configure ". 91 "it with the `%s' option in %s.", 92 'unit.csharp.xunit.binary', 93 '.arcconfig')); 94 } 95 } 96 97 /** 98 * Main entry point for the test engine. Determines what assemblies to build 99 * and test based on the files that have changed. 100 * 101 * @return array Array of test results. 102 */ 103 public function run() { 104 $this->loadEnvironment(); 105 106 if ($this->getRunAllTests()) { 107 $paths = id(new FileFinder($this->projectRoot))->find(); 108 } else { 109 $paths = $this->getPaths(); 110 } 111 112 return $this->runAllTests($this->mapPathsToResults($paths)); 113 } 114 115 /** 116 * Applies the discovery rules to the set of paths specified. 117 * 118 * @param array Array of paths. 119 * @return array Array of paths to test projects and assemblies. 120 */ 121 public function mapPathsToResults(array $paths) { 122 $results = array(); 123 foreach ($this->discoveryRules as $regex => $targets) { 124 $regex = str_replace('/', '\\/', $regex); 125 foreach ($paths as $path) { 126 if (preg_match('/'.$regex.'/', $path) === 1) { 127 foreach ($targets as $target) { 128 // Index 0 is the test project (.csproj file) 129 // Index 1 is the output assembly (.dll file) 130 $project = preg_replace('/'.$regex.'/', $target[0], $path); 131 $project = $this->projectRoot.DIRECTORY_SEPARATOR.$project; 132 $assembly = preg_replace('/'.$regex.'/', $target[1], $path); 133 $assembly = $this->projectRoot.DIRECTORY_SEPARATOR.$assembly; 134 if (file_exists($project)) { 135 $project = Filesystem::resolvePath($project); 136 $assembly = Filesystem::resolvePath($assembly); 137 138 // Check to ensure uniqueness. 139 $exists = false; 140 foreach ($results as $existing) { 141 if ($existing['assembly'] === $assembly) { 142 $exists = true; 143 break; 144 } 145 } 146 147 if (!$exists) { 148 $results[] = array( 149 'project' => $project, 150 'assembly' => $assembly, 151 ); 152 } 153 } 154 } 155 } 156 } 157 } 158 return $results; 159 } 160 161 /** 162 * Builds and runs the specified test assemblies. 163 * 164 * @param array Array of paths to test project files. 165 * @return array Array of test results. 166 */ 167 public function runAllTests(array $test_projects) { 168 if (empty($test_projects)) { 169 return array(); 170 } 171 172 $results = array(); 173 $results[] = $this->generateProjects(); 174 if ($this->resultsContainFailures($results)) { 175 return array_mergev($results); 176 } 177 $results[] = $this->buildProjects($test_projects); 178 if ($this->resultsContainFailures($results)) { 179 return array_mergev($results); 180 } 181 $results[] = $this->testAssemblies($test_projects); 182 183 return array_mergev($results); 184 } 185 186 /** 187 * Determine whether or not a current set of results contains any failures. 188 * This is needed since we build the assemblies as part of the unit tests, but 189 * we can't run any of the unit tests if the build fails. 190 * 191 * @param array Array of results to check. 192 * @return bool If there are any failures in the results. 193 */ 194 private function resultsContainFailures(array $results) { 195 $results = array_mergev($results); 196 foreach ($results as $result) { 197 if ($result->getResult() != ArcanistUnitTestResult::RESULT_PASS) { 198 return true; 199 } 200 } 201 return false; 202 } 203 204 /** 205 * If the `Build` directory exists, we assume that this is a multi-platform 206 * project that requires generation of C# project files. Because we want to 207 * test that the generation and subsequent build is whole, we need to 208 * regenerate any projects in case the developer has added files through an 209 * IDE and then forgotten to add them to the respective `.definitions` file. 210 * By regenerating the projects we ensure that any missing definition entries 211 * will cause the build to fail. 212 * 213 * @return array Array of test results. 214 */ 215 private function generateProjects() { 216 // No "Build" directory; so skip generation of projects. 217 if (!is_dir(Filesystem::resolvePath($this->projectRoot.'/Build'))) { 218 return array(); 219 } 220 221 // No "Protobuild.exe" file; so skip generation of projects. 222 if (!is_file(Filesystem::resolvePath( 223 $this->projectRoot.'/Protobuild.exe'))) { 224 225 return array(); 226 } 227 228 // Work out what platform the user is building for already. 229 $platform = phutil_is_windows() ? 'Windows' : 'Linux'; 230 $files = Filesystem::listDirectory($this->projectRoot); 231 foreach ($files as $file) { 232 if (strtolower(substr($file, -4)) == '.sln') { 233 $parts = explode('.', $file); 234 $platform = $parts[count($parts) - 2]; 235 break; 236 } 237 } 238 239 $regenerate_start = microtime(true); 240 $regenerate_future = new ExecFuture( 241 '%C Protobuild.exe --resync %s', 242 $this->runtimeEngine, 243 $platform); 244 $regenerate_future->setCWD(Filesystem::resolvePath( 245 $this->projectRoot)); 246 $results = array(); 247 $result = new ArcanistUnitTestResult(); 248 $result->setName(pht('(regenerate projects for %s)', $platform)); 249 250 try { 251 $regenerate_future->resolvex(); 252 $result->setResult(ArcanistUnitTestResult::RESULT_PASS); 253 } catch (CommandException $exc) { 254 if ($exc->getError() > 1) { 255 throw $exc; 256 } 257 $result->setResult(ArcanistUnitTestResult::RESULT_FAIL); 258 $result->setUserData($exc->getStdout()); 259 } 260 261 $result->setDuration(microtime(true) - $regenerate_start); 262 $results[] = $result; 263 return $results; 264 } 265 266 /** 267 * Build the projects relevant for the specified test assemblies and return 268 * the results of the builds as test results. This build also passes the 269 * "SkipTestsOnBuild" parameter when building the projects, so that MSBuild 270 * conditionals can be used to prevent any tests running as part of the 271 * build itself (since the unit tester is about to run each of the tests 272 * individually). 273 * 274 * @param array Array of test assemblies. 275 * @return array Array of test results. 276 */ 277 private function buildProjects(array $test_assemblies) { 278 $build_futures = array(); 279 $build_failed = false; 280 $build_start = microtime(true); 281 $results = array(); 282 foreach ($test_assemblies as $test_assembly) { 283 $build_future = new ExecFuture( 284 '%C %s', 285 $this->buildEngine, 286 '/p:SkipTestsOnBuild=True'); 287 $build_future->setCWD(Filesystem::resolvePath( 288 dirname($test_assembly['project']))); 289 $build_futures[$test_assembly['project']] = $build_future; 290 } 291 $iterator = id(new FutureIterator($build_futures))->limit(1); 292 foreach ($iterator as $test_assembly => $future) { 293 $result = new ArcanistUnitTestResult(); 294 $result->setName('(build) '.$test_assembly); 295 296 try { 297 $future->resolvex(); 298 $result->setResult(ArcanistUnitTestResult::RESULT_PASS); 299 } catch (CommandException $exc) { 300 if ($exc->getError() > 1) { 301 throw $exc; 302 } 303 $result->setResult(ArcanistUnitTestResult::RESULT_FAIL); 304 $result->setUserData($exc->getStdout()); 305 $build_failed = true; 306 } 307 308 $result->setDuration(microtime(true) - $build_start); 309 $results[] = $result; 310 } 311 return $results; 312 } 313 314 /** 315 * Build the future for running a unit test. This can be overridden to enable 316 * support for code coverage via another tool. 317 * 318 * @param string Name of the test assembly. 319 * @return array The future, output filename and coverage filename 320 * stored in an array. 321 */ 322 protected function buildTestFuture($test_assembly) { 323 // FIXME: Can't use TempFile here as xUnit doesn't like 324 // UNIX-style full paths. It sees the leading / as the 325 // start of an option flag, even when quoted. 326 $xunit_temp = Filesystem::readRandomCharacters(10).'.results.xml'; 327 if (file_exists($xunit_temp)) { 328 unlink($xunit_temp); 329 } 330 $future = new ExecFuture( 331 '%C %s /xml %s', 332 trim($this->runtimeEngine.' '.$this->testEngine), 333 $test_assembly, 334 $xunit_temp); 335 $folder = Filesystem::resolvePath($this->projectRoot); 336 $future->setCWD($folder); 337 $combined = $folder.'/'.$xunit_temp; 338 if (phutil_is_windows()) { 339 $combined = $folder.'\\'.$xunit_temp; 340 } 341 return array($future, $combined, null); 342 } 343 344 /** 345 * Run the xUnit test runner on each of the assemblies and parse the 346 * resulting XML. 347 * 348 * @param array Array of test assemblies. 349 * @return array Array of test results. 350 */ 351 private function testAssemblies(array $test_assemblies) { 352 $results = array(); 353 354 // Build the futures for running the tests. 355 $futures = array(); 356 $outputs = array(); 357 $coverages = array(); 358 foreach ($test_assemblies as $test_assembly) { 359 list($future_r, $xunit_temp, $coverage) = 360 $this->buildTestFuture($test_assembly['assembly']); 361 $futures[$test_assembly['assembly']] = $future_r; 362 $outputs[$test_assembly['assembly']] = $xunit_temp; 363 $coverages[$test_assembly['assembly']] = $coverage; 364 } 365 366 // Run all of the tests. 367 $futures = id(new FutureIterator($futures)) 368 ->limit(8); 369 foreach ($futures as $test_assembly => $future) { 370 list($err, $stdout, $stderr) = $future->resolve(); 371 372 if (file_exists($outputs[$test_assembly])) { 373 $result = $this->parseTestResult( 374 $outputs[$test_assembly], 375 $coverages[$test_assembly]); 376 $results[] = $result; 377 unlink($outputs[$test_assembly]); 378 } else { 379 // FIXME: There's a bug in Mono which causes a segmentation fault 380 // when xUnit.NET runs; this causes the XML file to not appear 381 // (depending on when the segmentation fault occurs). See 382 // https://bugzilla.xamarin.com/show_bug.cgi?id=16379 383 // for more information. 384 385 // Since it's not possible for the user to correct this error, we 386 // ignore the fact the tests didn't run here. 387 } 388 } 389 390 return array_mergev($results); 391 } 392 393 /** 394 * Returns null for this implementation as xUnit does not support code 395 * coverage directly. Override this method in another class to provide code 396 * coverage information (also see @{class:CSharpToolsUnitEngine}). 397 * 398 * @param string The name of the coverage file if one was provided by 399 * `buildTestFuture`. 400 * @return array Code coverage results, or null. 401 */ 402 protected function parseCoverageResult($coverage) { 403 return null; 404 } 405 406 /** 407 * Parses the test results from xUnit. 408 * 409 * @param string The name of the xUnit results file. 410 * @param string The name of the coverage file if one was provided by 411 * `buildTestFuture`. This is passed through to 412 * `parseCoverageResult`. 413 * @return array Test results. 414 */ 415 private function parseTestResult($xunit_tmp, $coverage) { 416 $xunit_dom = new DOMDocument(); 417 $xunit_dom->loadXML(Filesystem::readFile($xunit_tmp)); 418 419 $results = array(); 420 $tests = $xunit_dom->getElementsByTagName('test'); 421 foreach ($tests as $test) { 422 $name = $test->getAttribute('name'); 423 $time = $test->getAttribute('time'); 424 $status = ArcanistUnitTestResult::RESULT_UNSOUND; 425 switch ($test->getAttribute('result')) { 426 case 'Pass': 427 $status = ArcanistUnitTestResult::RESULT_PASS; 428 break; 429 case 'Fail': 430 $status = ArcanistUnitTestResult::RESULT_FAIL; 431 break; 432 case 'Skip': 433 $status = ArcanistUnitTestResult::RESULT_SKIP; 434 break; 435 } 436 $userdata = ''; 437 $reason = $test->getElementsByTagName('reason'); 438 $failure = $test->getElementsByTagName('failure'); 439 if ($reason->length > 0 || $failure->length > 0) { 440 $node = ($reason->length > 0) ? $reason : $failure; 441 $message = $node->item(0)->getElementsByTagName('message'); 442 if ($message->length > 0) { 443 $userdata = $message->item(0)->nodeValue; 444 } 445 $stacktrace = $node->item(0)->getElementsByTagName('stack-trace'); 446 if ($stacktrace->length > 0) { 447 $userdata .= "\n".$stacktrace->item(0)->nodeValue; 448 } 449 } 450 451 $result = new ArcanistUnitTestResult(); 452 $result->setName($name); 453 $result->setResult($status); 454 $result->setDuration($time); 455 $result->setUserData($userdata); 456 if ($coverage != null) { 457 $result->setCoverage($this->parseCoverageResult($coverage)); 458 } 459 $results[] = $result; 460 } 461 462 return $results; 463 } 464 465} 466