diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index d5815b13..b7454cdc 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1,207 +1,209 @@ array( 'ArcanistAliasWorkflow' => 'workflow/alias', 'ArcanistAmendWorkflow' => 'workflow/amend', 'ArcanistApacheLicenseLinter' => 'lint/linter/apachelicense', 'ArcanistApacheLicenseLinterTestCase' => 'lint/linter/apachelicense/__tests__', 'ArcanistBaseUnitTestEngine' => 'unit/engine/base', 'ArcanistBaseWorkflow' => 'workflow/base', 'ArcanistBranchWorkflow' => 'workflow/branch', 'ArcanistBundle' => 'parser/bundle', 'ArcanistBundleTestCase' => 'parser/bundle/__tests__', 'ArcanistCallConduitWorkflow' => 'workflow/call-conduit', 'ArcanistCapabilityNotSupportedException' => 'workflow/exception/notsupported', 'ArcanistChooseInvalidRevisionException' => 'exception', 'ArcanistChooseNoRevisionsException' => 'exception', 'ArcanistCloseRevisionWorkflow' => 'workflow/close-revision', 'ArcanistCloseWorkflow' => 'workflow/close', 'ArcanistCommentRemover' => 'parser/commentremover', 'ArcanistCommentRemoverTestCase' => 'parser/commentremover/__tests__', 'ArcanistCommitWorkflow' => 'workflow/commit', 'ArcanistConduitLinter' => 'lint/linter/conduit', 'ArcanistConfiguration' => 'configuration', 'ArcanistCoverWorkflow' => 'workflow/cover', 'ArcanistDiffChange' => 'parser/diff/change', 'ArcanistDiffChangeType' => 'parser/diff/changetype', 'ArcanistDiffHunk' => 'parser/diff/hunk', 'ArcanistDiffParser' => 'parser/diff', 'ArcanistDiffParserTestCase' => 'parser/diff/__tests__', 'ArcanistDiffUtils' => 'difference', 'ArcanistDiffUtilsTestCase' => 'difference/__tests__', 'ArcanistDiffWorkflow' => 'workflow/diff', 'ArcanistDifferentialCommitMessage' => 'differential/commitmessage', 'ArcanistDifferentialCommitMessageParserException' => 'differential/commitmessage', 'ArcanistDifferentialRevisionHash' => 'differential/constants/revisionhash', 'ArcanistDifferentialRevisionStatus' => 'differential/constants/revisionstatus', 'ArcanistDownloadWorkflow' => 'workflow/download', 'ArcanistEventType' => 'events/constant/type', 'ArcanistExportWorkflow' => 'workflow/export', 'ArcanistFilenameLinter' => 'lint/linter/filename', 'ArcanistGeneratedLinter' => 'lint/linter/generated', 'ArcanistGetConfigWorkflow' => 'workflow/get-config', 'ArcanistGitAPI' => 'repository/api/git', 'ArcanistGitHookPreReceiveWorkflow' => 'workflow/git-hook-pre-receive', 'ArcanistHelpWorkflow' => 'workflow/help', 'ArcanistHookAPI' => 'repository/hookapi/base', 'ArcanistInstallCertificateWorkflow' => 'workflow/install-certificate', 'ArcanistJSHintLinter' => 'lint/linter/jshint', 'ArcanistLandWorkflow' => 'workflow/land', 'ArcanistLiberateLintEngine' => 'lint/engine/liberate', 'ArcanistLiberateWorkflow' => 'workflow/liberate', 'ArcanistLicenseLinter' => 'lint/linter/license', 'ArcanistLintEngine' => 'lint/engine/base', 'ArcanistLintJSONRenderer' => 'lint/renderer', 'ArcanistLintLikeCompilerRenderer' => 'lint/renderer', 'ArcanistLintMessage' => 'lint/message', 'ArcanistLintPatcher' => 'lint/patcher', 'ArcanistLintRenderer' => 'lint/renderer', 'ArcanistLintResult' => 'lint/result', 'ArcanistLintSeverity' => 'lint/severity', 'ArcanistLintSummaryRenderer' => 'lint/renderer', 'ArcanistLintWorkflow' => 'workflow/lint', 'ArcanistLinter' => 'lint/linter/base', 'ArcanistLinterTestCase' => 'lint/linter/base/test', 'ArcanistListWorkflow' => 'workflow/list', 'ArcanistMarkCommittedWorkflow' => 'workflow/mark-committed', 'ArcanistMercurialAPI' => 'repository/api/mercurial', 'ArcanistMercurialParser' => 'repository/parser/mercurial', 'ArcanistMercurialParserTestCase' => 'repository/parser/mercurial/__tests__', 'ArcanistNoEffectException' => 'exception/usage/noeffect', 'ArcanistNoEngineException' => 'exception/usage/noengine', 'ArcanistNoLintLinter' => 'lint/linter/nolint', 'ArcanistNoLintTestCaseMisnamed' => 'lint/linter/nolint/__tests__', 'ArcanistPEP8Linter' => 'lint/linter/pep8', 'ArcanistPasteWorkflow' => 'workflow/paste', 'ArcanistPatchWorkflow' => 'workflow/patch', 'ArcanistPhutilModuleLinter' => 'lint/linter/phutilmodule', 'ArcanistPhutilTestCase' => 'unit/engine/phutil/testcase', 'ArcanistPhutilTestSkippedException' => 'unit/engine/phutil/testcase/exception', 'ArcanistPhutilTestTerminatedException' => 'unit/engine/phutil/testcase/exception', 'ArcanistPyFlakesLinter' => 'lint/linter/pyflakes', 'ArcanistPyLintLinter' => 'lint/linter/pylint', 'ArcanistRepositoryAPI' => 'repository/api/base', 'ArcanistSetConfigWorkflow' => 'workflow/set-config', 'ArcanistShellCompleteWorkflow' => 'workflow/shell-complete', 'ArcanistSpellingDefaultData' => 'lint/linter/spelling', 'ArcanistSpellingLinter' => 'lint/linter/spelling', 'ArcanistSpellingLinterTestCase' => 'lint/linter/spelling/__tests__', 'ArcanistSubversionAPI' => 'repository/api/subversion', 'ArcanistSubversionHookAPI' => 'repository/hookapi/subversion', 'ArcanistSvnHookPreCommitWorkflow' => 'workflow/svn-hook-pre-commit', 'ArcanistTasksWorkflow' => 'workflow/tasks', 'ArcanistTextLinter' => 'lint/linter/text', 'ArcanistTextLinterTestCase' => 'lint/linter/text/__tests__', 'ArcanistUncommittedChangesException' => 'exception/usage/uncommittedchanges', 'ArcanistUnitTestResult' => 'unit/result', 'ArcanistUnitWorkflow' => 'workflow/unit', 'ArcanistUpgradeWorkflow' => 'workflow/upgrade', 'ArcanistUploadWorkflow' => 'workflow/upload', 'ArcanistUsageException' => 'exception/usage', 'ArcanistUserAbortException' => 'exception/usage/userabort', 'ArcanistWhichWorkflow' => 'workflow/which', 'ArcanistWorkingCopyIdentity' => 'workingcopyidentity', 'ArcanistXHPASTLintNamingHook' => 'lint/linter/xhpast/naminghook', 'ArcanistXHPASTLintNamingHookTestCase' => 'lint/linter/xhpast/naminghook/__tests__', 'ArcanistXHPASTLinter' => 'lint/linter/xhpast', 'ArcanistXHPASTLinterTestCase' => 'lint/linter/xhpast/__tests__', 'BranchInfo' => 'branch', 'ComprehensiveLintEngine' => 'lint/engine/comprehensive', 'ExampleLintEngine' => 'lint/engine/example', 'NoseTestEngine' => 'unit/engine/nose', + 'PhpunitTestEngine' => 'unit/engine/phpunit', 'PhutilLintEngine' => 'lint/engine/phutil', 'PhutilModuleRequirements' => 'parser/phutilmodule', 'PhutilUnitTestEngine' => 'unit/engine/phutil', 'PhutilUnitTestEngineTestCase' => 'unit/engine/phutil/__tests__', 'UnitTestableArcanistLintEngine' => 'lint/engine/test', ), 'function' => array( ), 'requires_class' => array( 'ArcanistAliasWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistAmendWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistApacheLicenseLinter' => 'ArcanistLicenseLinter', 'ArcanistApacheLicenseLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistBranchWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistBundleTestCase' => 'ArcanistPhutilTestCase', 'ArcanistCallConduitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistCloseRevisionWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistCloseWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistCommentRemoverTestCase' => 'ArcanistPhutilTestCase', 'ArcanistCommitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistConduitLinter' => 'ArcanistLinter', 'ArcanistCoverWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistDiffParserTestCase' => 'ArcanistPhutilTestCase', 'ArcanistDiffUtilsTestCase' => 'ArcanistPhutilTestCase', 'ArcanistDiffWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistDownloadWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistEventType' => 'PhutilEventType', 'ArcanistExportWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistFilenameLinter' => 'ArcanistLinter', 'ArcanistGeneratedLinter' => 'ArcanistLinter', 'ArcanistGetConfigWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistGitAPI' => 'ArcanistRepositoryAPI', 'ArcanistGitHookPreReceiveWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistHelpWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistInstallCertificateWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistJSHintLinter' => 'ArcanistLinter', 'ArcanistLandWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistLiberateLintEngine' => 'ArcanistLintEngine', 'ArcanistLiberateWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistLicenseLinter' => 'ArcanistLinter', 'ArcanistLintWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistLinterTestCase' => 'ArcanistPhutilTestCase', 'ArcanistListWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistMarkCommittedWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistMercurialAPI' => 'ArcanistRepositoryAPI', 'ArcanistMercurialParserTestCase' => 'ArcanistPhutilTestCase', 'ArcanistNoEffectException' => 'ArcanistUsageException', 'ArcanistNoEngineException' => 'ArcanistUsageException', 'ArcanistNoLintLinter' => 'ArcanistLinter', 'ArcanistNoLintTestCaseMisnamed' => 'ArcanistLinterTestCase', 'ArcanistPEP8Linter' => 'ArcanistLinter', 'ArcanistPasteWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistPatchWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistPhutilModuleLinter' => 'ArcanistLinter', 'ArcanistPyFlakesLinter' => 'ArcanistLinter', 'ArcanistPyLintLinter' => 'ArcanistLinter', 'ArcanistSetConfigWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistShellCompleteWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistSpellingLinter' => 'ArcanistLinter', 'ArcanistSpellingLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistSubversionAPI' => 'ArcanistRepositoryAPI', 'ArcanistSubversionHookAPI' => 'ArcanistHookAPI', 'ArcanistSvnHookPreCommitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistTasksWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistTextLinter' => 'ArcanistLinter', 'ArcanistTextLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistUncommittedChangesException' => 'ArcanistUsageException', 'ArcanistUnitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistUpgradeWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistUploadWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistUserAbortException' => 'ArcanistUsageException', 'ArcanistWhichWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistXHPASTLintNamingHookTestCase' => 'ArcanistPhutilTestCase', 'ArcanistXHPASTLinter' => 'ArcanistLinter', 'ArcanistXHPASTLinterTestCase' => 'ArcanistLinterTestCase', 'ComprehensiveLintEngine' => 'ArcanistLintEngine', 'ExampleLintEngine' => 'ArcanistLintEngine', 'NoseTestEngine' => 'ArcanistBaseUnitTestEngine', + 'PhpunitTestEngine' => 'ArcanistBaseUnitTestEngine', 'PhutilLintEngine' => 'ArcanistLintEngine', 'PhutilUnitTestEngine' => 'ArcanistBaseUnitTestEngine', 'PhutilUnitTestEngineTestCase' => 'ArcanistPhutilTestCase', 'UnitTestableArcanistLintEngine' => 'ArcanistLintEngine', ), 'requires_interface' => array( ), )); diff --git a/src/unit/engine/phpunit/PhpunitTestEngine.php b/src/unit/engine/phpunit/PhpunitTestEngine.php new file mode 100644 index 00000000..a9f1ba4b --- /dev/null +++ b/src/unit/engine/phpunit/PhpunitTestEngine.php @@ -0,0 +1,336 @@ +projectRoot = $this->getWorkingCopy()->getProjectRoot(); + + $this->affectedTests = array(); + foreach ($this->getPaths() as $path) { + + $path = Filesystem::resolvePath($path); + + // 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) { + $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('phpunit %C --log-json %s %C %s', + $config, $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_path, + $tmpfiles[$test]['json'], + $tmpfiles[$test]['clover']); + } + + return array_mergev($results); + } + + /** + * We need this non-sense to make json generated by phpunit + * valid. + * + * @param string $json_tmp Path to JSON report + * + * @return array JSON decoded array + */ + private function getJsonReport($json_tmp) { + $json = Filesystem::readFile($json_tmp); + + 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 = str_replace('}{"', '},{"', $json); + $json = '[' . $json . ']'; + $json = json_decode($json); + if (!is_array($json)) { + throw new Exception('JSON could not be decoded'); + } + + return $json; + } + + /** + * Parse test results from phpunit json report + * + * @param string $path Path to test + * @param string $json_path Path to phpunit json report + * @param string $clover_tmp Path to phpunit clover report + * + * @return array + */ + private function parseTestResults($path, $json_tmp, $clover_tmp) { + $test_results = Filesystem::readFile($json_tmp); + + $report = $this->getJsonReport($json_tmp); + + // coverage is for all testcases in the executed $path + $coverage = array(); + if ($this->getEnableCoverage() !== false) { + $coverage = $this->readCoverage($clover_tmp); + } + + $results = array(); + foreach ($report as $event) { + if ('test' != $event->event) { + continue; + } + + $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 ('Skipped Test' == $event->message) { + $status = ArcanistUnitTestResult::RESULT_SKIP; + $user_data .= $event->message; + } else if ('Incomplete Test' == $event->message) { + $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 = substr($event->test, strlen($event->suite) + 2); + $result = new ArcanistUnitTestResult(); + $result->setName($name); + $result->setResult($status); + $result->setDuration($event->time); + $result->setCoverage($coverage); + $result->setUserData($user_data); + + $results[] = $result; + } + + return $results; + } + + /** + * Red the coverage from phpunit generated clover report + * + * @param string $path Path to report + * + * @return array + */ + private function readCoverage($path) { + $test_results = Filesystem::readFile($path); + if (empty($test_results)) { + throw new Exception('Clover coverage XML report file is empty'); + } + + $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; + } + + + /** + * Some nasty guessing here. + * + * Walk up to the project root trying to find + * [Tt]ests directory and replicate the structure there. + * + * Assume that the class path is + * /www/project/module/package/subpackage/FooBar.php + * and a project root is /www/project it will look for it by these paths: + * /www/project/module/package/subpackage/[Tt]ests/FooBarTest.php + * /www/project/module/package/[Tt]ests/subpackage/FooBarTest.php + * /www/project/module/[Tt]ests/package/subpackage/FooBarTest.php + * /www/project/Tt]ests/module/package/subpackage/FooBarTest.php + * + * TODO: Add support for finding tests based on PSR-1 naming conventions: + * /www/project/src/Something/Foo/Bar.php tests should be detected in + * /www/project/tests/Something/Foo/BarTest.php + * + * TODO: Add support for finding tests in testsuite folders from + * phpunit.xml configuration. + * + * @param string $path + * + * @return string|boolean + */ + private function findTestFile($path) { + $expected_file = substr(basename($path), 0, -4) . 'Test.php'; + $expected_dir = null; + $dirname = dirname($path); + foreach (Filesystem::walkToRoot($dirname) as $dir) { + $expected_dir = DIRECTORY_SEPARATOR + . substr($dirname, strlen($dir) + 1) + . $expected_dir; + $look_for = $dir . DIRECTORY_SEPARATOR + . '%s' . $expected_dir . $expected_file; + + if (Filesystem::pathExists(sprintf($look_for, 'Tests'))) { + return sprintf($look_for, 'Tests'); + } else if (Filesystem::pathExists(sprintf($look_for, 'Tests'))) { + return sprintf($look_for, 'Tests'); + } + + if ($dir == $this->projectRoot) { + break; + } + + } + + return false; + } + + /** + * Tries to find and update phpunit configuration file + * based on phpunit_config option in .arcconfig + */ + private function prepareConfigFile() { + $project_root = $this->projectRoot . DIRECTORY_SEPARATOR; + + if ($config = $this->getWorkingCopy()->getConfig('phpunit_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); + } + } + } +} diff --git a/src/unit/engine/phpunit/__init__.php b/src/unit/engine/phpunit/__init__.php new file mode 100644 index 00000000..a39a3d7a --- /dev/null +++ b/src/unit/engine/phpunit/__init__.php @@ -0,0 +1,21 @@ +