diff --git a/src/unit/engine/PhpunitTestEngine.php b/src/unit/engine/PhpunitTestEngine.php index 558616ac..82438631 100644 --- a/src/unit/engine/PhpunitTestEngine.php +++ b/src/unit/engine/PhpunitTestEngine.php @@ -1,277 +1,276 @@ 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; $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'], $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, $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 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 { + } else { $this->phpunitBinary = Filesystem::resolvePath($bin, $project_root); } } } } diff --git a/src/unit/engine/PytestTestEngine.php b/src/unit/engine/PytestTestEngine.php index 8d3891b9..11315e3f 100644 --- a/src/unit/engine/PytestTestEngine.php +++ b/src/unit/engine/PytestTestEngine.php @@ -1,144 +1,143 @@ getWorkingCopy(); $this->project_root = $working_copy->getProjectRoot(); $junit_tmp = new TempFile(); $cover_tmp = new TempFile(); $future = $this->buildTestFuture($junit_tmp, $cover_tmp); list($err, $stdout, $stderr) = $future->resolve(); if (!Filesystem::pathExists($junit_tmp)) { throw new CommandException( "Command failed with error #{$err}!", $future->getCommand(), $err, $stdout, $stderr); } $future = new ExecFuture('coverage xml -o %s', $cover_tmp); $future->setCWD($this->project_root); $future->resolvex(); return $this->parseTestResults($junit_tmp, $cover_tmp); } public function buildTestFuture($junit_tmp, $cover_tmp) { $paths = $this->getPaths(); $cmd_line = csprintf('py.test --junit-xml=%s', $junit_tmp); if ($this->getEnableCoverage() !== false) { $cmd_line = csprintf( 'coverage run --source %s -m %C', $this->project_root, $cmd_line); } return new ExecFuture('%C', $cmd_line); } public function parseTestResults($junit_tmp, $cover_tmp) { $parser = new ArcanistXUnitTestResultParser(); $results = $parser->parseTestResults( Filesystem::readFile($junit_tmp)); if ($this->getEnableCoverage() !== false) { $coverage_report = $this->readCoverage($cover_tmp); foreach ($results as $result) { $result->setCoverage($coverage_report); } } return $results; } public function readCoverage($path) { $coverage_data = Filesystem::readFile($path); if (empty($coverage_data)) { return array(); } $coverage_dom = new DOMDocument(); $coverage_dom->loadXML($coverage_data); $paths = $this->getPaths(); $reports = array(); $classes = $coverage_dom->getElementsByTagName('class'); foreach ($classes as $class) { // filename is actually python module path with ".py" at the end, // e.g.: tornado.web.py $relative_path = explode('.', $class->getAttribute('filename')); array_pop($relative_path); $relative_path = implode('/', $relative_path); // first we check if the path is a directory (a Python package), if it is // set relative and absolute paths to have __init__.py at the end. $absolute_path = Filesystem::resolvePath($relative_path); if (is_dir($absolute_path)) { $relative_path .= '/__init__.py'; $absolute_path .= '/__init__.py'; } // then we check if the path with ".py" at the end is file (a Python // submodule), if it is - set relative and absolute paths to have // ".py" at the end. if (is_file($absolute_path.'.py')) { $relative_path .= '.py'; $absolute_path .= '.py'; } if (!file_exists($absolute_path)) { continue; } if (!in_array($relative_path, $paths)) { continue; } // get total line count in file $line_count = count(file($absolute_path)); $coverage = ''; $start_line = 1; $lines = $class->getElementsByTagName('line'); for ($ii = 0; $ii < $lines->length; $ii++) { $line = $lines->item($ii); $next_line = intval($line->getAttribute('number')); for ($start_line; $start_line < $next_line; $start_line++) { $coverage .= 'N'; } if (intval($line->getAttribute('hits')) == 0) { $coverage .= 'U'; - } - else if (intval($line->getAttribute('hits')) > 0) { + } else if (intval($line->getAttribute('hits')) > 0) { $coverage .= 'C'; } $start_line++; } if ($start_line < $line_count) { foreach (range($start_line, $line_count) as $line_num) { $coverage .= 'N'; } } $reports[$relative_path] = $coverage; } return $reports; } } diff --git a/src/workflow/ArcanistBackoutWorkflow.php b/src/workflow/ArcanistBackoutWorkflow.php index 7d37853a..7131d777 100644 --- a/src/workflow/ArcanistBackoutWorkflow.php +++ b/src/workflow/ArcanistBackoutWorkflow.php @@ -1,191 +1,190 @@ | Entering a differential revision will only work if there is only one commit associated with the revision. This requires your working copy is up to date and that the commit exists in the working copy. EOTEXT ); } public function getArguments() { return array( '*' => 'input', ); } public function requiresWorkingCopy() { return true; } public function requiresRepositoryAPI() { return true; } public function requiresAuthentication() { return true; } /** * Given a differential revision ID, fetches the commit ID. */ private function getCommitIDFromRevisionID($revision_id) { $conduit = $this->getConduit(); $revisions = $conduit->callMethodSynchronous( 'differential.query', array( 'ids' => array($revision_id), )); if (!$revisions) { throw new ArcanistUsageException( 'The revision you provided does not exist!'); } $revision = $revisions[0]; $commits = $revision['commits']; if (!$commits) { throw new ArcanistUsageException( 'This revision has not been committed yet!'); - } - else if (count($commits) > 1) { + } else if (count($commits) > 1) { throw new ArcanistUsageException( 'The revision you provided has multiple commits!'); } $commit_phid = $commits[0]; $commit = $conduit->callMethodSynchronous( 'phid.query', array( 'phids' => array($commit_phid), )); $commit_id = $commit[$commit_phid]['name']; return $commit_id; } /** * Fetches an array of commit info provided a Commit_id in the form of * rE123456 (not local commit hash). */ private function getDiffusionCommit($commit_id) { $result = $this->getConduit()->callMethodSynchronous( 'diffusion.getcommits', array( 'commits' => array($commit_id), )); $commit = $result[$commit_id]; // This commit was not found in Diffusion if (array_key_exists('error', $commit)) { return null; } return $commit; } /** * Retrieves default template from differential and pre-fills info. */ private function buildCommitMessage($commit_hash) { $conduit = $this->getConduit(); $repository_api = $this->getRepositoryAPI(); $summary = $repository_api->getBackoutMessage($commit_hash); $fields = array( 'summary' => $summary, 'testPlan' => 'revert-hammer', ); $template = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => null, 'edit' => 'create', 'fields' => $fields, )); $template = $this->newInteractiveEditor($template) ->setName('new-commit') ->editInteractively(); return $template; } /** * Performs the backout/revert of a revision and creates a commit. */ public function run() { $console = PhutilConsole::getConsole(); $conduit = $this->getConduit(); $repository_api = $this->getRepositoryAPI(); $is_git_svn = $repository_api instanceof ArcanistGitAPI && $repository_api->isGitSubversionRepo(); $is_hg_svn = $repository_api instanceof ArcanistMercurialAPI && $repository_api->isHgSubversionRepo(); $revision_id = null; if (!($repository_api instanceof ArcanistGitAPI) && !($repository_api instanceof ArcanistMercurialAPI)) { throw new ArcanistUsageException( 'Backout currently only supports Git and Mercurial' ); } $console->writeOut("Starting backout\n"); $input = $this->getArgument('input'); if (!$input || count($input) != 1) { throw new ArcanistUsageException( 'You must specify one commit to backout!'); } // Input looks like a Differential revision, so // we try to find the commit attached to it $matches = array(); if (preg_match('/^D(\d+)$/i', $input[0], $matches)) { $revision_id = $matches[1]; $commit_id = $this->getCommitIDFromRevisionID($revision_id); $commit = $this->getDiffusionCommit($commit_id); $commit_hash = $commit['commitIdentifier']; // Convert commit hash from SVN to Git/HG (for FB case) if ($is_git_svn || $is_hg_svn) { $commit_hash = $repository_api-> getHashFromFromSVNRevisionNumber($commit_hash); } } else { // Assume input is a commit hash $commit_hash = $input[0]; } if (!$repository_api->hasLocalCommit($commit_hash)) { throw new ArcanistUsageException( 'Invalid commit provided or does not exist in the working copy!'); } // Run 'backout'. $subject = $repository_api->getCommitSummary($commit_hash); $console->writeOut("Backing out commit {$commit_hash} {$subject} \n"); $repository_api->backoutCommit($commit_hash); // Create commit message and execute the commit $message = $this->buildCommitMessage($commit_hash); $repository_api->doCommit($message); $console->writeOut("Double-check the commit and push when ready\n"); } }