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