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