diff --git a/.arcconfig b/.arcconfig --- a/.arcconfig +++ b/.arcconfig @@ -1,7 +1,7 @@ { "project.name" : "arcanist", "phabricator.uri" : "https://secure.phabricator.com/", - "unit.engine" : "PhutilUnitTestEngine", + "unit.engine" : "ConfigDrivenMetaEngine", "load" : [ "src/" ] diff --git a/.arcunit b/.arcunit new file mode 100644 --- /dev/null +++ b/.arcunit @@ -0,0 +1,7 @@ +{ + "engines": { + "putil": { + "engine": "phutil" + } + } +} diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -201,6 +201,7 @@ 'ArcanistXUnitTestResultParser' => 'unit/engine/ArcanistXUnitTestResultParser.php', 'CSharpToolsTestEngine' => 'unit/engine/CSharpToolsTestEngine.php', 'ComprehensiveLintEngine' => 'lint/engine/ComprehensiveLintEngine.php', + 'ConfigDrivenMetaEngine' => 'unit/engine/ConfigDrivenMetaEngine.php', 'GoTestResultParser' => 'unit/engine/GoTestResultParser.php', 'GoTestResultParserTestCase' => 'unit/engine/__tests__/GoTestResultParserTestCase.php', 'NoseTestEngine' => 'unit/engine/NoseTestEngine.php', @@ -373,6 +374,7 @@ 'ArcanistXMLLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'CSharpToolsTestEngine' => 'XUnitTestEngine', 'ComprehensiveLintEngine' => 'ArcanistLintEngine', + 'ConfigDrivenMetaEngine' => 'ArcanistUnitTestEngine', 'GoTestResultParser' => 'ArcanistTestResultParser', 'GoTestResultParserTestCase' => 'ArcanistTestCase', 'NoseTestEngine' => 'ArcanistUnitTestEngine', diff --git a/src/unit/engine/ArcanistUnitTestEngine.php b/src/unit/engine/ArcanistUnitTestEngine.php --- a/src/unit/engine/ArcanistUnitTestEngine.php +++ b/src/unit/engine/ArcanistUnitTestEngine.php @@ -6,7 +6,8 @@ abstract class ArcanistUnitTestEngine { private $workingCopy; - private $paths; + private $paths; // TODO deprecate + private $pathsMap; private $arguments = array(); protected $diffID; private $enableAsyncTests; @@ -117,4 +118,18 @@ return true; } + public function setPathsMap($paths_map) { + $this->pathsMap = $paths_map; + return $this; + } + public function getPathsMap() { + return $this->pathsMap; + } + public function getEngineConfigurationName() { + return null; + } + public function getEngineConfigurationOptions() { + return array(); + } + public function setUnitConfigurationValue($key, $value) {} } diff --git a/src/unit/engine/ConfigDrivenMetaEngine.php b/src/unit/engine/ConfigDrivenMetaEngine.php new file mode 100644 --- /dev/null +++ b/src/unit/engine/ConfigDrivenMetaEngine.php @@ -0,0 +1,269 @@ +buildEngines(); + $results = array(); + + foreach ($engines as $engine) { + $engine + ->setWorkingCopy($this->getWorkingCopy()) + ->setConfigurationManager($this->getConfigurationManager()) + ->setRenderer($this->renderer) + ->setEnableCoverage($this->getEnableCoverage()) + ->setEnableAsyncTests(false); // TODO handle this + // There's something called Arguments(), but it should diaf. + + $results[] = $engine->run(); + } + + $results = array_mergev($results); + return $results; + } + + public function buildEngines() { + $working_copy = $this->getWorkingCopy(); + $config_path = $working_copy->getProjectPath('.arcunit'); + + if (!Filesystem::pathExists($config_path)) { + throw new Exception( + "Unable to find '.arcunit' file to configure tests. Create a ". + "'.arcunit' file in the root directory of the working copy."); + } + + $data = Filesystem::readFile($config_path); + $config = null; + try { + $config = phutil_json_decode($data); + } catch (PhutilJSONParserException $ex) { + throw new PhutilProxyException( + pht( + "Expected '.arcunit' file to be a valid JSON file, but failed to ". + "decode %s", + $config_path), + $ex); + } + + $engines = $this->loadAvailableEngines(); + + try { + PhutilTypeSpec::checkMap( + $config, + array( + 'exclude' => 'optional regex | list', + 'engines' => 'map>', // I'm not sold on this name. Any suggestions? + )); + } catch (PhutilTypeCheckException $ex) { + $message = pht( + 'Error in parsing ".arcunit" file: %s', + $ex->getMessage()); + throw new PhutilProxyException($message, $ex); + } + + $global_exclude = (array)idx($config, 'exclude', array()); + + $built_engines = array(); + $all_paths = $this->getPaths(); + foreach ($config['engines'] as $name => $spec) { + $type = idx($spec, 'engine'); + if ($type !== null) { + if (empty($engines[$type])) { + $list = implode(', ', array_keys($engines)); + throw new Exception( + "Engine '{$name}' specifies invalid type '{$type}'. Available ". + "engines are: {$list}."); + } + + $engine = clone $engines[$type]; + $more = $engine->getEngineConfigurationOptions(); + + foreach ($more as $key => $option_spec) { + PhutilTypeSpec::checkMap( + $option_spec, + array( + 'type' => 'string', + 'help' => 'string', + )); + $more[$key] = $option_spec['type']; + } + } else { + // We'll raise an error below about the invalid "type" key. + $engine = null; + $more = array(); + } + + try { + PhutilTypeSpec::checkMap( + $spec, + array( + 'engine' => 'string', + 'paths' => 'optional map', + 'exclude' => 'optional regex | list', + ) + $more); + } catch (PhutilTypeCheckException $ex) { + $message = pht( + 'Error in parsing ".arcunit" file, for engine "%s": %s', + $name, + $ex->getMessage()); + throw new PhutilProxyException($message, $ex); + } + + foreach ($more as $key => $value) { + if (array_key_exists($key, $spec)) { + try { + $engine->setUnitConfigurationValue($key, $spec[$key]); + } catch (Exception $ex) { + $message = pht( + 'Error in parsing ".arcunit" file, in key "%s" for '. + 'engine "%s": %s', + $key, + $name, + $ex->getMessage()); + throw new PhutilProxyException($message, $ex); + } + } + } + + $engine_paths = (array)idx($spec, 'paths', array()); + $exclude = (array)idx($spec, 'exclude', array()); + + $console = PhutilConsole::getConsole(); + $console->writeLog("Examining paths for engine \"%s\".\n", $name); + + if ($this->getRunAllTests()) { + $engine->setRunAllTests($this->getRunAllTests()); + $built_engines[] = $engine; + } else { + $paths = $this->matchPaths( // This is now a map of {file => maybe-test-file-for-it} + $all_paths, + $engine_paths, + $exclude, + $global_exclude); + $console->writeLog( + "Found %d matching paths for engine \"%s\".\n", + count($paths), + $name); + + if ($paths) { + $engine->setPathsMap($paths); // TODO rename? + $built_engines[] = $engine; + } + } + } + + return $built_engines; + } + + private function loadAvailableEngines() { + $engines = id(new PhutilSymbolLoader()) + ->setAncestorClass('ArcanistUnitTestEngine') + ->loadObjects(); + + $map = array(); + foreach ($engines as $engine) { + $name = $engine->getEngineConfigurationName(); + + // This engine isn't selectable through configuration. + if ($name === null) { + continue; + } + + if (empty($map[$name])) { + $map[$name] = $engine; + continue; + } + + $orig_class = get_class($map[$name]); + $this_class = get_class($engine); + throw new Exception( + "Two engines ({$orig_class}, {$this_class}) both have the same ". + "configuration name ({$name}). Engines must have unique configuration ". + "names."); + } + + return $map; + } + + private function matchPaths( + array $paths, + array $engine_paths, + array $exclude, + array $global_exclude) { + + $console = PhutilConsole::getConsole(); + + $match = array(); + foreach ($paths as $path) { + $console->writeLog("Examining path '%s'...\n", $path); + + $replaced = null; + $keep = false; + if (!$engine_paths) { + $keep = true; + $console->writeLog( + " Including path by default because there is no 'paths' rule.\n"); + $replaced = $path; + } else { + $console->writeLog(" Testing \"paths\" rules.\n"); + foreach ($engine_paths as $rule => $replace) { + if (preg_match($rule, $path)) { + $keep = true; + $replaced = preg_replace($rule, $replace, $path); + $console->writeLog(" Path matches include rule: %s\n", $rule); + break; + } else { + $console->writeLog( + " Path does not match include rule: %s\n", + $rule); + } + } + } + + if (!$keep) { + $console->writeLog( + " Path does not match any rules, discarding.\n"); + continue; + } + + if ($exclude) { + $console->writeLog(" Testing \"exclude\" rules.\n"); + foreach ($exclude as $rule) { + if (preg_match($rule, $path)) { + $console->writeLog(" Path matches \"exclude\" rule: %s\n", $rule); + continue 2; + } else { + $console->writeLog( + " Path does not match \"exclude\" rule: %s\n", + $rule); + } + } + } + + if ($global_exclude) { + $console->writeLog(" Testing global \"exclude\" rules.\n"); + foreach ($global_exclude as $rule) { + if (preg_match($rule, $path)) { + $console->writeLog( + " Path matches global \"exclude\" rule: %s\n", + $rule); + continue 2; + } else { + $console->writeLog( + " Path does not match global \"exclude\" rule: %s\n", + $rule); + } + } + } + + $console->writeLog(" Path matches.\n"); + $match[$path] = $replaced; + } + + return $match; + } + + protected function supportsRunAllTests() { + return true; + } +} diff --git a/src/unit/engine/NoseTestEngine.php b/src/unit/engine/NoseTestEngine.php --- a/src/unit/engine/NoseTestEngine.php +++ b/src/unit/engine/NoseTestEngine.php @@ -3,11 +3,23 @@ /** * Very basic 'nose' unit test engine wrapper. * - * Requires nose 1.1.3 for code coverage. + * Requires nose 1.1.3 and python-coverage 3.6 for code coverage. */ final class NoseTestEngine extends ArcanistUnitTestEngine { + private $sourcePath = './'; public function run() { + if ($this->getPaths()) { + return $this->runAutoGuessTests(); + } + + $paths = $this->getPathsMap(); + $test_paths = array_unique($paths); + + return $this->runTests($test_paths, $this->sourcePath); + } + + private function runAutoGuessTests() { // Possibly not used by anyone anywhere. $paths = $this->getPaths(); $affected_tests = array(); @@ -115,7 +127,6 @@ $reports = array(); $classes = $coverage_dom->getElementsByTagName('class'); - foreach ($classes as $class) { $path = $class->getAttribute('filename'); $root = $this->getWorkingCopy()->getProjectRoot(); @@ -159,4 +170,24 @@ return $reports; } + public function getEngineConfigurationName() { + return 'pynose'; + } + + public function setUnitConfigurationValue($key, $value) { + switch ($key) { + case 'source_path': + $this->sourcePath = $value; + return; + } + return parent::setUnitConfigurationValue($key, $value); + } + public function getEngineConfigurationOptions() { + return array( + 'source_path' => array( + 'type' => 'optional string', + 'help' => 'Root of source code. Defaults to repository\'s root.', + ), + ); + } } diff --git a/src/unit/engine/PhutilUnitTestEngine.php b/src/unit/engine/PhutilUnitTestEngine.php --- a/src/unit/engine/PhutilUnitTestEngine.php +++ b/src/unit/engine/PhutilUnitTestEngine.php @@ -105,7 +105,11 @@ $look_here = array(); - foreach ($this->getPaths() as $path) { + $paths = $this->getPaths(); + if (!$paths) { + $paths = array_values($this->getPathsMap()); + } + foreach ($paths as $path) { $library_root = phutil_get_library_root_for_path($path); if (!$library_root) { continue; @@ -188,4 +192,7 @@ return !$this->renderer; } + public function getEngineConfigurationName() { + return 'phutil'; + } }