Page Menu
Home
Sealhub
Search
Configure Global Search
Log In
Files
F995583
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
19 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/unit/ArcanistUnitTestResult.php b/src/unit/ArcanistUnitTestResult.php
index 6372efcc..a004ee68 100644
--- a/src/unit/ArcanistUnitTestResult.php
+++ b/src/unit/ArcanistUnitTestResult.php
@@ -1,128 +1,127 @@
<?php
/**
* Represents the outcome of running a unit test.
*
* @group unit
*/
final class ArcanistUnitTestResult {
const RESULT_PASS = 'pass';
const RESULT_FAIL = 'fail';
const RESULT_SKIP = 'skip';
const RESULT_BROKEN = 'broken';
const RESULT_UNSOUND = 'unsound';
const RESULT_POSTPONED = 'postponed';
- private $namespace;
private $name;
private $link;
private $result;
private $duration;
private $userData;
private $extraData;
private $coverage;
public function setName($name) {
$this->name = $name;
return $this;
}
public function getName() {
return $this->name;
}
public function setLink($link) {
$this->link = $link;
return $this;
}
public function getLink() {
return $this->link;
}
public function setResult($result) {
$this->result = $result;
return $this;
}
public function getResult() {
return $this->result;
}
public function setDuration($duration) {
$this->duration = $duration;
return $this;
}
public function getDuration() {
return $this->duration;
}
public function setUserData($user_data) {
$this->userData = $user_data;
return $this;
}
public function getUserData() {
return $this->userData;
}
/**
* "extra data" allows an implementation to store additional
* key/value metadata along with the result of the test run.
*/
public function setExtraData(array $extra_data = null) {
$this->extraData = $extra_data;
return $this;
}
public function getExtraData() {
return $this->extraData;
}
public function setCoverage($coverage) {
$this->coverage = $coverage;
return $this;
}
public function getCoverage() {
return $this->coverage;
}
/**
* Merge several coverage reports into a comprehensive coverage report.
*
* @param list List of coverage report strings.
* @return string Cumulative coverage report.
*/
public static function mergeCoverage(array $coverage) {
if (empty($coverage)) {
return null;
}
$base = reset($coverage);
foreach ($coverage as $more_coverage) {
$len = min(strlen($base), strlen($more_coverage));
for ($ii = 0; $ii < $len; $ii++) {
if ($more_coverage[$ii] == 'C') {
$base[$ii] = 'C';
}
}
}
return $base;
}
public function toDictionary() {
return array(
'name' => $this->getName(),
'link' => $this->getLink(),
'result' => $this->getResult(),
'duration' => $this->getDuration(),
'extra' => $this->getExtraData(),
'userData' => $this->getUserData(),
'coverage' => $this->getCoverage(),
);
}
}
diff --git a/src/unit/engine/ArcanistBaseTestResultParser.php b/src/unit/engine/ArcanistBaseTestResultParser.php
index 97eea371..d10fe123 100644
--- a/src/unit/engine/ArcanistBaseTestResultParser.php
+++ b/src/unit/engine/ArcanistBaseTestResultParser.php
@@ -1,46 +1,52 @@
<?php
/**
* Abstract Base class for test result parsers
*/
abstract class ArcanistBaseTestResultParser {
protected $enableCoverage;
protected $projectRoot;
protected $coverageFile;
+ protected $stderr;
public function setEnableCoverage($enable_coverage) {
$this->enableCoverage = $enable_coverage;
return $this;
}
public function setProjectRoot($project_root) {
$this->projectRoot = $project_root;
return $this;
}
public function setCoverageFile($coverage_file) {
$this->coverageFile = $coverage_file;
return $this;
}
public function setAffectedTests($affected_tests) {
$this->affectedTests = $affected_tests;
return $this;
}
+ public function setStderr($stderr) {
+ $this->stderr = $stderr;
+ return $this;
+ }
+
/**
* Parse test results from provided input and return an array
* of ArcanistUnitTestResult
*
* @param string $path Path to test
* @param string $test_results String containing test results
*
* @return array ArcanistUnitTestResult
*/
abstract public function parseTestResults($path, $test_results);
}
diff --git a/src/unit/engine/PhpunitResultParser.php b/src/unit/engine/PhpunitResultParser.php
index 146ba6d2..152d337d 100644
--- a/src/unit/engine/PhpunitResultParser.php
+++ b/src/unit/engine/PhpunitResultParser.php
@@ -1,173 +1,189 @@
<?php
/**
* PHPUnit Result Parsing utility
*
* Intended to enable custom unit engines derived
* from phpunit to reuse common business logic related
* to parsing phpunit test results and reports
*
* For an example on how to integrate with your test
* engine, see PhpunitTestEngine.
*
*/
final class PhpunitResultParser extends ArcanistBaseTestResultParser {
/**
* Parse test results from phpunit json report
*
* @param string $path Path to test
* @param string $test_results String containing phpunit json report
*
* @return array
*/
public function parseTestResults($path, $test_results) {
+ if (!$test_results) {
+ $result = id(new ArcanistUnitTestResult())
+ ->setName($path)
+ ->setUserData($this->stderr)
+ ->setResult(ArcanistUnitTestResult::RESULT_BROKEN);
+ return array($result);
+ }
+
$report = $this->getJsonReport($test_results);
// coverage is for all testcases in the executed $path
$coverage = array();
if ($this->enableCoverage !== false) {
$coverage = $this->readCoverage();
}
$results = array();
foreach ($report as $event) {
- if ('test' != $event->event) {
- continue;
+ switch ($event->event) {
+ case 'test':
+ break;
+ case 'testStart':
+ $lastTestFinished = false;
+ // fall through
+ default:
+ continue 2; // switch + loop
}
$status = ArcanistUnitTestResult::RESULT_PASS;
$user_data = '';
if ('fail' == $event->status) {
$status = ArcanistUnitTestResult::RESULT_FAIL;
$user_data .= $event->message . "\n";
foreach ($event->trace as $trace) {
$user_data .= sprintf("\n%s:%s", $trace->file, $trace->line);
}
} else if ('error' == $event->status) {
if (strpos($event->message, 'Skipped Test') !== false) {
$status = ArcanistUnitTestResult::RESULT_SKIP;
$user_data .= $event->message;
} else if (strpos($event->message, 'Incomplete Test') !== false) {
$status = ArcanistUnitTestResult::RESULT_SKIP;
$user_data .= $event->message;
} else {
$status = ArcanistUnitTestResult::RESULT_BROKEN;
$user_data .= $event->message;
foreach ($event->trace as $trace) {
$user_data .= sprintf("\n%s:%s", $trace->file, $trace->line);
}
}
}
$name = preg_replace('/ \(.*\)/s', '', $event->test);
$result = new ArcanistUnitTestResult();
$result->setName($name);
$result->setResult($status);
$result->setDuration($event->time);
$result->setCoverage($coverage);
$result->setUserData($user_data);
$results[] = $result;
+ $lastTestFinished = true;
}
+ if (!$lastTestFinished) {
+ $results[] = id(new ArcanistUnitTestResult())
+ ->setName($event->test) // use last event
+ ->setUserData($this->stderr)
+ ->setResult(ArcanistUnitTestResult::RESULT_BROKEN);
+ }
return $results;
}
/**
* Read the coverage from phpunit generated clover report
*
* @return array
*/
private function readCoverage() {
$test_results = Filesystem::readFile($this->coverageFile);
if (empty($test_results)) {
- throw new Exception('Clover coverage XML report file is empty, '
- . 'it probably means that phpunit failed to run tests. '
- . 'Try running arc unit with --trace option and then run '
- . 'generated phpunit command yourself, you might get the '
- . 'answer.'
- );
+ return array();
}
$coverage_dom = new DOMDocument();
$coverage_dom->loadXML($test_results);
$reports = array();
$files = $coverage_dom->getElementsByTagName('file');
foreach ($files as $file) {
$class_path = $file->getAttribute('name');
if (empty($this->affectedTests[$class_path])) {
continue;
}
$test_path = $this->affectedTests[$file->getAttribute('name')];
// get total line count in file
$line_count = count(file($class_path));
$coverage = '';
$start_line = 1;
$lines = $file->getElementsByTagName('line');
for ($ii = 0; $ii < $lines->length; $ii++) {
$line = $lines->item($ii);
for (; $start_line < $line->getAttribute('num'); $start_line++) {
$coverage .= 'N';
}
if ($line->getAttribute('type') != 'stmt') {
$coverage .= 'N';
} else {
if ((int) $line->getAttribute('count') == 0) {
$coverage .= 'U';
} else if ((int) $line->getAttribute('count') > 0) {
$coverage .= 'C';
}
}
$start_line++;
}
for (; $start_line <= $line_count; $start_line++) {
$coverage .= 'N';
}
$len = strlen($this->projectRoot . DIRECTORY_SEPARATOR);
$class_path = substr($class_path, $len);
$reports[$class_path] = $coverage;
}
return $reports;
}
/**
* We need this non-sense to make json generated by phpunit
* valid.
*
* @param string $json String containing JSON report
*
* @return array JSON decoded array
*/
private function getJsonReport($json) {
if (empty($json)) {
throw new Exception('JSON report file is empty, '
. 'it probably means that phpunit failed to run tests. '
. 'Try running arc unit with --trace option and then run '
. 'generated phpunit command yourself, you might get the '
. 'answer.'
);
}
$json = preg_replace('/}{\s*"/', '},{"', $json);
$json = '[' . $json . ']';
$json = json_decode($json);
if (!is_array($json)) {
throw new Exception('JSON could not be decoded');
}
return $json;
}
}
diff --git a/src/unit/engine/PhpunitTestEngine.php b/src/unit/engine/PhpunitTestEngine.php
index 4a29af66..95ea8adc 100644
--- a/src/unit/engine/PhpunitTestEngine.php
+++ b/src/unit/engine/PhpunitTestEngine.php
@@ -1,282 +1,287 @@
<?php
/**
* PHPUnit wrapper
*
* To use, set unit.engine in .arcconfig, or use --engine flag
* with arc unit. Currently supports only class & test files
* (no directory support).
* To use custom phpunit configuration, set phpunit_config in
* .arcconfig (e.g. app/phpunit.xml.dist).
*
* @group unitrun
*/
final class PhpunitTestEngine extends ArcanistBaseUnitTestEngine {
private $configFile;
private $phpunitBinary = 'phpunit';
private $affectedTests;
private $projectRoot;
public function run() {
$this->projectRoot = $this->getWorkingCopy()->getProjectRoot();
$this->affectedTests = array();
foreach ($this->getPaths() as $path) {
$path = Filesystem::resolvePath($path, $this->projectRoot);
// TODO: add support for directories
// Users can call phpunit on the directory themselves
if (is_dir($path)) {
continue;
}
// Not sure if it would make sense to go further if
// it is not a .php file
if (substr($path, -4) != '.php') {
continue;
}
if (substr($path, -8) == 'Test.php') {
// Looks like a valid test file name.
$this->affectedTests[$path] = $path;
continue;
}
if ($test = $this->findTestFile($path)) {
$this->affectedTests[$path] = $test;
}
}
if (empty($this->affectedTests)) {
throw new ArcanistNoEffectException('No tests to run.');
}
$this->prepareConfigFile();
$futures = array();
$tmpfiles = array();
foreach ($this->affectedTests as $class_path => $test_path) {
if(!Filesystem::pathExists($test_path)) {
continue;
}
$json_tmp = new TempFile();
$clover_tmp = null;
$clover = null;
if ($this->getEnableCoverage() !== false) {
$clover_tmp = new TempFile();
$clover = csprintf('--coverage-clover %s', $clover_tmp);
}
$config = $this->configFile ? csprintf('-c %s', $this->configFile) : null;
- $futures[$test_path] = new ExecFuture('%C %C --log-json %s %C %s',
- $this->phpunitBinary, $config, $json_tmp, $clover, $test_path);
+ $stderr = "-d display_errors=stderr";
+
+ $futures[$test_path] = new ExecFuture('%C %C %C --log-json %s %C %s',
+ $this->phpunitBinary, $config, $stderr, $json_tmp, $clover, $test_path);
$tmpfiles[$test_path] = array(
'json' => $json_tmp,
'clover' => $clover_tmp,
);
}
$results = array();
foreach (Futures($futures)->limit(4) as $test => $future) {
list($err, $stdout, $stderr) = $future->resolve();
$results[] = $this->parseTestResults(
$test,
$tmpfiles[$test]['json'],
- $tmpfiles[$test]['clover']);
+ $tmpfiles[$test]['clover'],
+ $stderr);
}
return array_mergev($results);
}
/**
* Parse test results from phpunit json report
*
* @param string $path Path to test
* @param string $json_tmp Path to phpunit json report
* @param string $clover_tmp Path to phpunit clover report
+ * @param string $stderr Data written to stderr
*
* @return array
*/
- private function parseTestResults($path, $json_tmp, $clover_tmp) {
+ private function parseTestResults($path, $json_tmp, $clover_tmp, $stderr) {
$test_results = Filesystem::readFile($json_tmp);
return id(new PhpunitResultParser())
->setEnableCoverage($this->getEnableCoverage())
->setProjectRoot($this->projectRoot)
->setCoverageFile($clover_tmp)
->setAffectedTests($this->affectedTests)
+ ->setStderr($stderr)
->parseTestResults($path, $test_results);
}
/**
* Search for test cases for a given file in a large number of "reasonable"
* locations. See @{method:getSearchLocationsForTests} for specifics.
*
* TODO: Add support for finding tests in testsuite folders from
* phpunit.xml configuration.
*
* @param string PHP file to locate test cases for.
* @return string|null Path to test cases, or null.
*/
private function findTestFile($path) {
$root = $this->projectRoot;
$path = Filesystem::resolvePath($path, $root);
$file = basename($path);
$possible_files = array(
$file,
substr($file, 0, -4).'Test.php',
);
$search = self::getSearchLocationsForTests($path);
foreach ($search as $search_path) {
foreach ($possible_files as $possible_file) {
$full_path = $search_path.$possible_file;
if (!Filesystem::pathExists($full_path)) {
// If the file doesn't exist, it's clearly a miss.
continue;
}
if (!Filesystem::isDescendant($full_path, $root)) {
// Don't look above the project root.
continue;
}
if (0 == strcasecmp(Filesystem::resolvePath($full_path), $path)) {
// Don't return the original file.
continue;
}
return $full_path;
}
}
return null;
}
/**
* Get places to look for PHP Unit tests that cover a given file. For some
* file "/a/b/c/X.php", we look in the same directory:
*
* /a/b/c/
*
* We then look in all parent directories for a directory named "tests/"
* (or "Tests/"):
*
* /a/b/c/tests/
* /a/b/tests/
* /a/tests/
* /tests/
*
* We also try to replace each directory component with "tests/":
*
* /a/b/tests/
* /a/tests/c/
* /tests/b/c/
*
* We also try to add "tests/" at each directory level:
*
* /a/b/c/tests/
* /a/b/tests/c/
* /a/tests/b/c/
* /tests/a/b/c/
*
* This finds tests with a layout like:
*
* docs/
* src/
* tests/
*
* ...or similar. This list will be further pruned by the caller; it is
* intentionally filesystem-agnostic to be unit testable.
*
* @param string PHP file to locate test cases for.
* @return list<string> List of directories to search for tests in.
*/
public static function getSearchLocationsForTests($path) {
$file = basename($path);
$dir = dirname($path);
$test_dir_names = array('tests', 'Tests');
$try_directories = array();
// Try in the current directory.
$try_directories[] = array($dir);
// Try in a tests/ directory anywhere in the ancestry.
foreach (Filesystem::walkToRoot($dir) as $parent_dir) {
if ($parent_dir == '/') {
// We'll restore this later.
$parent_dir = '';
}
foreach ($test_dir_names as $test_dir_name) {
$try_directories[] = array($parent_dir, $test_dir_name);
}
}
// Try replacing each directory component with 'tests/'.
$parts = trim($dir, DIRECTORY_SEPARATOR);
$parts = explode(DIRECTORY_SEPARATOR, $parts);
foreach (array_reverse(array_keys($parts)) as $key) {
foreach ($test_dir_names as $test_dir_name) {
$try = $parts;
$try[$key] = $test_dir_name;
array_unshift($try, '');
$try_directories[] = $try;
}
}
// Try adding 'tests/' at each level.
foreach (array_reverse(array_keys($parts)) as $key) {
foreach ($test_dir_names as $test_dir_name) {
$try = $parts;
$try[$key] = $test_dir_name.DIRECTORY_SEPARATOR.$try[$key];
array_unshift($try, '');
$try_directories[] = $try;
}
}
$results = array();
foreach ($try_directories as $parts) {
$results[implode(DIRECTORY_SEPARATOR, $parts).DIRECTORY_SEPARATOR] = true;
}
return array_keys($results);
}
/**
* Tries to find and update phpunit configuration file
* based on phpunit_config option in .arcconfig
*/
private function prepareConfigFile() {
$project_root = $this->projectRoot . DIRECTORY_SEPARATOR;
$config = $this->getConfigurationManager()->getConfigFromAnySource(
'phpunit_config');
if ($config) {
if (Filesystem::pathExists($project_root . $config)) {
$this->configFile = $project_root . $config;
} else {
throw new Exception('PHPUnit configuration file was not ' .
'found in ' . $project_root . $config);
}
}
$bin = $this->getConfigurationManager()->getConfigFromAnySource(
'unit.phpunit.binary');
if ($bin) {
if (Filesystem::binaryExists($bin)) {
$this->phpunitBinary = $bin;
}
else {
$this->phpunitBinary = Filesystem::resolvePath($bin, $project_root);
}
}
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Mon, Dec 23, 08:24 (1 d, 3 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
556880
Default Alt Text
(19 KB)
Attached To
Mode
R118 Arcanist - fork
Attached
Detach File
Event Timeline
Log In to Comment