~ubuntu-branches/ubuntu/wily/phabricator/wily

« back to all changes in this revision

Viewing changes to src/unit/engine/XUnitTestEngine.php

  • Committer: Package Import Robot
  • Author(s): Richard Sellam
  • Date: 2014-11-01 23:20:06 UTC
  • mto: This revision was merged to the branch mainline in revision 4.
  • Revision ID: package-import@ubuntu.com-20141101232006-mvlnp0cil67tsboe
Tags: upstream-0~git20141101/arcanist
Import upstream version 0~git20141101, component arcanist

Show diffs side-by-side

added added

removed removed

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