1<?php
2
3/**
4 * Very basic 'nose' unit test engine wrapper.
5 *
6 * Requires nose 1.1.3 for code coverage.
7 */
8final class NoseTestEngine extends ArcanistUnitTestEngine {
9
10  private $parser;
11
12  protected function supportsRunAllTests() {
13    return true;
14  }
15
16  public function run() {
17    if ($this->getRunAllTests()) {
18      $root = $this->getWorkingCopy()->getProjectRoot();
19      $all_tests = glob(Filesystem::resolvePath("$root/tests/**/test_*.py"));
20      return $this->runTests($all_tests, $root);
21    }
22
23    $paths = $this->getPaths();
24
25    $affected_tests = array();
26    foreach ($paths as $path) {
27      $absolute_path = Filesystem::resolvePath($path);
28
29      if (is_dir($absolute_path)) {
30        $absolute_test_path = Filesystem::resolvePath('tests/'.$path);
31        if (is_readable($absolute_test_path)) {
32          $affected_tests[] = $absolute_test_path;
33        }
34      }
35
36      if (is_readable($absolute_path)) {
37        $filename = basename($path);
38        $directory = dirname($path);
39
40        // assumes directory layout: tests/<package>/test_<module>.py
41        $relative_test_path = 'tests/'.$directory.'/test_'.$filename;
42        $absolute_test_path = Filesystem::resolvePath($relative_test_path);
43
44        if (is_readable($absolute_test_path)) {
45          $affected_tests[] = $absolute_test_path;
46        }
47      }
48    }
49
50    return $this->runTests($affected_tests, './');
51  }
52
53  public function runTests($test_paths, $source_path) {
54    if (empty($test_paths)) {
55      return array();
56    }
57
58    $futures = array();
59    $tmpfiles = array();
60    foreach ($test_paths as $test_path) {
61      $xunit_tmp = new TempFile();
62      $cover_tmp = new TempFile();
63
64      $future = $this->buildTestFuture($test_path, $xunit_tmp, $cover_tmp);
65
66      $futures[$test_path] = $future;
67      $tmpfiles[$test_path] = array(
68        'xunit' => $xunit_tmp,
69        'cover' => $cover_tmp,
70      );
71    }
72
73    $results = array();
74    $futures = id(new FutureIterator($futures))
75      ->limit(4);
76    foreach ($futures as $test_path => $future) {
77      try {
78        list($stdout, $stderr) = $future->resolvex();
79      } catch (CommandException $exc) {
80        if ($exc->getError() > 1) {
81          // 'nose' returns 1 when tests are failing/broken.
82          throw $exc;
83        }
84      }
85
86      $xunit_tmp = $tmpfiles[$test_path]['xunit'];
87      $cover_tmp = $tmpfiles[$test_path]['cover'];
88
89      $this->parser = new ArcanistXUnitTestResultParser();
90      $results[] = $this->parseTestResults(
91        $source_path,
92        $xunit_tmp,
93        $cover_tmp);
94    }
95
96    return array_mergev($results);
97  }
98
99  public function buildTestFuture($path, $xunit_tmp, $cover_tmp) {
100    $cmd_line = csprintf(
101      'nosetests --with-xunit --xunit-file=%s',
102      $xunit_tmp);
103
104    if ($this->getEnableCoverage() !== false) {
105      $cmd_line .= csprintf(
106        ' --with-coverage --cover-xml --cover-xml-file=%s',
107        $cover_tmp);
108    }
109
110    return new ExecFuture('%C %s', $cmd_line, $path);
111  }
112
113  public function parseTestResults($source_path, $xunit_tmp, $cover_tmp) {
114    $results = $this->parser->parseTestResults(
115      Filesystem::readFile($xunit_tmp));
116
117    // coverage is for all testcases in the executed $path
118    if ($this->getEnableCoverage() !== false) {
119      $coverage = $this->readCoverage($cover_tmp, $source_path);
120      foreach ($results as $result) {
121        $result->setCoverage($coverage);
122      }
123    }
124
125    return $results;
126  }
127
128  public function readCoverage($cover_file, $source_path) {
129    $coverage_xml = Filesystem::readFile($cover_file);
130    if (strlen($coverage_xml) < 1) {
131      return array();
132    }
133    $coverage_dom = new DOMDocument();
134    $coverage_dom->loadXML($coverage_xml);
135
136    $reports = array();
137    $classes = $coverage_dom->getElementsByTagName('class');
138
139    foreach ($classes as $class) {
140      $path = $class->getAttribute('filename');
141      $root = $this->getWorkingCopy()->getProjectRoot();
142
143      if (!Filesystem::isDescendant($path, $root)) {
144        continue;
145      }
146
147      // get total line count in file
148      $line_count = count(phutil_split_lines(Filesystem::readFile($path)));
149
150      $coverage = '';
151      $start_line = 1;
152      $lines = $class->getElementsByTagName('line');
153      for ($ii = 0; $ii < $lines->length; $ii++) {
154        $line = $lines->item($ii);
155
156        $next_line = (int)$line->getAttribute('number');
157        for ($start_line; $start_line < $next_line; $start_line++) {
158          $coverage .= 'N';
159        }
160
161        if ((int)$line->getAttribute('hits') == 0) {
162          $coverage .= 'U';
163        } else if ((int)$line->getAttribute('hits') > 0) {
164          $coverage .= 'C';
165        }
166
167        $start_line++;
168      }
169
170      if ($start_line < $line_count) {
171        foreach (range($start_line, $line_count) as $line_num) {
172          $coverage .= 'N';
173        }
174      }
175
176      $reports[$path] = $coverage;
177    }
178
179    return $reports;
180  }
181
182}
183