diff --git a/PHPCI/Languages/lang.en.php b/PHPCI/Languages/lang.en.php index 63df75d2..b725ee76 100644 --- a/PHPCI/Languages/lang.en.php +++ b/PHPCI/Languages/lang.en.php @@ -181,6 +181,7 @@ PHPCI', 'phpcs_errors' => 'PHPCS Errors', 'phplint_errors' => 'Lint Errors', 'phpunit_errors' => 'PHPUnit Errors', + 'phpunit_fail_init' => 'Neither a configuration file nor a test directory found.', 'phpdoccheck_warnings' => 'Missing Docblocks', 'issues' => 'Issues', diff --git a/PHPCI/Languages/lang.es.php b/PHPCI/Languages/lang.es.php index 1b7b33f7..35b6c580 100644 --- a/PHPCI/Languages/lang.es.php +++ b/PHPCI/Languages/lang.es.php @@ -176,6 +176,7 @@ PHPCI', 'phpcs_errors' => 'PHPCS Errors', 'phplint_errors' => 'Lint Errors', 'phpunit_errors' => 'PHPUnit Errors', + 'phpunit_fail_init' => 'No se encontro archivo o folder de pruevas.', 'phpdoccheck_warnings' => 'Docblocks faltantes', 'issues' => 'Incidencias', diff --git a/PHPCI/Plugin/Option/PhpUnitOptions.php b/PHPCI/Plugin/Option/PhpUnitOptions.php new file mode 100644 index 00000000..4387da1c --- /dev/null +++ b/PHPCI/Plugin/Option/PhpUnitOptions.php @@ -0,0 +1,272 @@ + + * @package PHPCI + * @subpackage Plugin + */ +class PhpUnitOptions +{ + protected $options; + protected $arguments = array(); + + public function __construct($options) + { + $this->options = $options; + } + + /** + * Remove a command argument + * + * @param $argumentName + * + * @return $this + */ + public function removeArgument($argumentName) + { + unset($this->arguments[$argumentName]); + return $this; + } + + /** + * Combine all the argument into a string for the phpunit command + * + * @return string + */ + public function buildArgumentString() + { + $argumentString = ''; + foreach ($this->getCommandArguments() as $argumentName => $argumentValues) { + $prefix = $argumentName[0] == '-' ? '' : '--'; + + if (!is_array($argumentValues)) { + $argumentValues = array($argumentValues); + } + + foreach ($argumentValues as $argValue) { + $postfix = ' '; + if (!empty($argValue)) { + $postfix = ' "' . $argValue . '" '; + } + $argumentString .= $prefix . $argumentName . $postfix; + } + } + + return $argumentString; + } + + /** + * Get all the command arguments + * + * @return string[] + */ + public function getCommandArguments() + { + /* + * Return the full list of arguments + */ + return $this->parseArguments()->arguments; + } + + /** + * Parse the arguments from the config options + * + * @return $this + */ + private function parseArguments() + { + if (empty($this->arguments)) { + /* + * Parse the arguments from the YML options file + */ + if (isset($this->options['args'])) { + $rawArgs = $this->options['args']; + if (is_array($rawArgs)) { + $this->arguments = $rawArgs; + } else { + /* + * Try to parse old arguments in a single string + */ + preg_match_all('@--([a-z\-]+)([\s=]+)?[\'"]?((?!--)[-\w/.,\\\]+)?[\'"]?@', (string)$rawArgs, $argsMatch); + + if (!empty($argsMatch) && sizeof($argsMatch) > 2) { + foreach ($argsMatch[1] as $index => $argName) { + $this->addArgument($argName, $argsMatch[3][$index]); + } + } + } + } + + /* + * Handles command aliases outside of the args option + */ + if (isset($this->options['coverage'])) { + $this->addArgument('coverage-html', $this->options['coverage']); + } + + /* + * Handles command aliases outside of the args option + */ + if (isset($this->options['config'])) { + $this->addArgument('configuration', $this->options['config']); + } + } + + return $this; + } + + /** + * Add an argument to the collection + * Note: adding argument before parsing the options will prevent the other options from been parsed. + * + * @param string $argumentName + * @param string $argumentValue + */ + public function addArgument($argumentName, $argumentValue) + { + if (isset($this->arguments[$argumentName])) { + if (!is_array($this->arguments[$argumentName])) { + // Convert existing argument values into an array + $this->arguments[$argumentName] = array($this->arguments[$argumentName]); + } + + // Appends the new argument to the list + $this->arguments[$argumentName][] = $argumentValue; + } else { + // Adds new argument + $this->arguments[$argumentName] = $argumentValue; + } + } + + /** + * Get the list of directory to run phpunit in + * + * @return string[] List of directories + */ + public function getDirectories() + { + $directories = $this->getOption('directory'); + + if (is_string($directories)) { + $directories = array($directories); + } else { + if (is_null($directories)) { + $directories = array(); + } + } + + return is_array($directories) ? $directories : array($directories); + } + + /** + * Get an option if defined + * + * @param $optionName + * + * @return string[]|string|null + */ + public function getOption($optionName) + { + if (isset($this->options[$optionName])) { + return $this->options[$optionName]; + } + + return null; + } + + /** + * Get the directory to execute the command from + * + * @return mixed|null + */ + public function getRunFrom() + { + return $this->getOption('run_from'); + } + + /** + * Ge the directory name where tests file reside + * + * @return string|null + */ + public function getTestsPath() + { + return $this->getOption('path'); + } + + /** + * Get the PHPUnit configuration from the options, or the optional path + * + * @param string $altPath + * + * @return string[] path of files + */ + public function getConfigFiles($altPath = '') + { + $configFiles = $this->getArgument('configuration'); + if (empty($configFiles)) { + $configFile = self::findConfigFile($altPath); + if ($configFile) { + $configFiles[] = $configFile; + } + } + + return $configFiles; + } + + /** + * Get options for a given argument + * + * @param $argumentName + * + * @return string[] All the options for given argument + */ + public function getArgument($argumentName) + { + $this->parseArguments(); + + if (isset($this->arguments[$argumentName])) { + return is_array( + $this->arguments[$argumentName] + ) ? $this->arguments[$argumentName] : array($this->arguments[$argumentName]); + } + + return array(); + } + + /** + * Find a PHPUnit configuration file in a directory + * + * @param string $buildPath The path to configuration file + * + * @return null|string + */ + public static function findConfigFile($buildPath) + { + $files = array( + 'phpunit.xml', + 'phpunit.xml.dist', + 'tests/phpunit.xml', + 'tests/phpunit.xml.dist', + ); + + foreach ($files as $file) { + if (is_file($buildPath . $file)) { + return $file; + } + } + + return null; + } +} diff --git a/PHPCI/Plugin/PhpUnit.php b/PHPCI/Plugin/PhpUnit.php index f716f079..ca8c5bc2 100644 --- a/PHPCI/Plugin/PhpUnit.php +++ b/PHPCI/Plugin/PhpUnit.php @@ -11,54 +11,59 @@ namespace PHPCI\Plugin; use PHPCI; use PHPCI\Builder; +use PHPCI\Helper\Lang; use PHPCI\Model\Build; -use PHPCI\Plugin\Util\TapParser; +use PHPCI\Model\BuildError; +use PHPCI\Plugin\Option\PhpUnitOptions; +use PHPCI\Plugin\Util\PhpUnitResult; /** -* PHP Unit Plugin - Allows PHP Unit testing. -* @author Dan Cryer -* @package PHPCI -* @subpackage Plugins -*/ + * PHP Unit Plugin - A rewrite of the original PHP Unit plugin + * + * @author Dan Cryer + * @author Pablo Tejada + * @package PHPCI + * @subpackage Plugins + */ class PhpUnit implements PHPCI\Plugin, PHPCI\ZeroConfigPlugin { - protected $args; protected $phpci; protected $build; + /** @var string[] Raw options from the PHPCI config file */ + protected $options = array(); + /** - * @var string|string[] $directory The directory (or array of dirs) to run PHPUnit on + * Standard Constructor + * $options['config'] Path to a PHPUnit XML configuration file. + * $options['run_from'] The directory where the phpunit command will run from when using 'config'. + * $options['coverage'] Value for the --coverage-html command line flag. + * $options['directory'] Optional directory or list of directories to run PHPUnit on. + * $options['args'] Command line args (in string format) to pass to PHP Unit + * + * @param Builder $phpci + * @param Build $build + * @param string[] $options */ - protected $directory; + public function __construct(Builder $phpci, Build $build, array $options = array()) + { + $this->phpci = $phpci; + $this->build = $build; + $this->options = new PhpUnitOptions($options); + } /** - * @var string $runFrom When running PHPUnit with an XML config, the command is run from this directory - */ - protected $runFrom; - - /** - * @var string, in cases where tests files are in a sub path of the /tests path, - * allows this path to be set in the config. - */ - protected $path; - - protected $coverage = ""; - - /** - * @var string|string[] $xmlConfigFile The path (or array of paths) of an xml config for PHPUnit - */ - protected $xmlConfigFile; - - /** - * Check if this plugin can be executed. - * @param $stage + * Check if the plugin can be executed without any configurations + * + * @param $stage * @param Builder $builder - * @param Build $build + * @param Build $build + * * @return bool */ public static function canExecute($stage, Builder $builder, Build $build) { - if ($stage == 'test' && !is_null(self::findConfigFile($builder->buildPath))) { + if ($stage == 'test' && !is_null(PhpUnitOptions::findConfigFile($build->getBuildPath()))) { return true; } @@ -66,183 +71,138 @@ class PhpUnit implements PHPCI\Plugin, PHPCI\ZeroConfigPlugin } /** - * Try and find the phpunit XML config file. - * @param $buildPath - * @return null|string + * Runs PHP Unit tests in a specified directory, optionally using specified config file(s). */ - public static function findConfigFile($buildPath) - { - if (file_exists($buildPath . 'phpunit.xml')) { - return 'phpunit.xml'; - } - - if (file_exists($buildPath . 'tests' . DIRECTORY_SEPARATOR . 'phpunit.xml')) { - return 'tests' . DIRECTORY_SEPARATOR . 'phpunit.xml'; - } - - if (file_exists($buildPath . 'phpunit.xml.dist')) { - return 'phpunit.xml.dist'; - } - - if (file_exists($buildPath . 'tests/phpunit.xml.dist')) { - return 'tests' . DIRECTORY_SEPARATOR . 'phpunit.xml.dist'; - } - - return null; - } - - /** - * Standard Constructor - * - * $options['directory'] Output Directory. Default: %BUILDPATH% - * $options['filename'] Phar Filename. Default: build.phar - * $options['regexp'] Regular Expression Filename Capture. Default: /\.php$/ - * $options['stub'] Stub Content. No Default Value - * - * @param Builder $phpci - * @param Build $build - * @param array $options - */ - public function __construct(Builder $phpci, Build $build, array $options = array()) - { - $this->phpci = $phpci; - $this->build = $build; - - if (empty($options['config']) && empty($options['directory'])) { - $this->xmlConfigFile = self::findConfigFile($phpci->buildPath); - } - - if (isset($options['directory'])) { - $this->directory = $options['directory']; - } - - if (isset($options['config'])) { - $this->xmlConfigFile = $options['config']; - } - - if (isset($options['run_from'])) { - $this->runFrom = $options['run_from']; - } - - if (isset($options['args'])) { - $this->args = $this->phpci->interpolate($options['args']); - } - - if (isset($options['path'])) { - $this->path = $options['path']; - } - - if (isset($options['coverage'])) { - $this->coverage = ' --coverage-html ' . $this->phpci->interpolate($options['coverage']) . ' '; - } - } - - /** - * Runs PHP Unit tests in a specified directory, optionally using specified config file(s). - */ public function execute() { - if (empty($this->xmlConfigFile) && empty($this->directory)) { - $this->phpci->logFailure('Neither configuration file nor test directory found.'); + $xmlConfigFiles = $this->options->getConfigFiles($this->build->getBuildPath()); + $directories = $this->options->getDirectories(); + if (empty($xmlConfigFiles) && empty($directories)) { + $this->phpci->logFailure(Lang::get('phpunit_fail_init')); return false; } - $success = true; + $success = array(); - $this->phpci->logExecOutput(false); - - // Run any config files first. This can be either a single value or an array. - if ($this->xmlConfigFile !== null) { - $success &= $this->runConfigFile($this->xmlConfigFile); + // Run any directories + if (!empty($directories)) { + foreach ($directories as $directory) { + $success[] = $this->runDir($directory); + } + } else { + // Run any config files + if (!empty($xmlConfigFiles)) { + foreach ($xmlConfigFiles as $configFile) { + $success[] = $this->runConfigFile($configFile); + } + } } - // Run any dirs next. Again this can be either a single value or an array. - if ($this->directory !== null) { - $success &= $this->runDir($this->directory); - } + return !in_array(false, $success); + } - $tapString = $this->phpci->getLastOutput(); - $tapString = mb_convert_encoding($tapString, "UTF-8", "ISO-8859-1"); + /** + * Run the PHPUnit tests in a specific directory or array of directories. + * + * @param $directory + * + * @return bool|mixed + */ + protected function runDir($directory) + { + $options = clone $this->options; - try { - $tapParser = new TapParser($tapString); - $output = $tapParser->parse(); - } catch (\Exception $ex) { - $this->phpci->logFailure($tapString); - throw $ex; - } + $buildPath = $this->build->getBuildPath() . DIRECTORY_SEPARATOR; - $failures = $tapParser->getTotalFailures(); + $currentPath = getcwd(); + // Change the directory + chdir($buildPath); - $this->build->storeMeta('phpunit-errors', $failures); - $this->build->storeMeta('phpunit-data', $output); + // Save the results into a json file + $jsonFile = tempnam(dirname($buildPath), 'jLog_'); + $options->addArgument('log-json', $jsonFile); - $this->phpci->logExecOutput(true); + // Removes any current configurations files + $options->removeArgument('configuration'); + + $arguments = $this->phpci->interpolate($options->buildArgumentString()); + $cmd = $this->phpci->findBinary('phpunit') . ' %s "%s"'; + $success = $this->phpci->executeCommand($cmd, $arguments, $directory); + + // Change to che original path + chdir($currentPath); + + $this->processResults($jsonFile); return $success; } /** * Run the tests defined in a PHPUnit config file. - * @param $configPath + * + * @param $configFile + * * @return bool|mixed */ - protected function runConfigFile($configPath) + protected function runConfigFile($configFile) { - if (is_array($configPath)) { - return $this->recurseArg($configPath, array($this, "runConfigFile")); - } else { - if ($this->runFrom) { - $curdir = getcwd(); - chdir($this->phpci->buildPath . DIRECTORY_SEPARATOR . $this->runFrom); - } + $options = clone $this->options; + $runFrom = $options->getRunFrom(); - $phpunit = $this->phpci->findBinary('phpunit'); - - $cmd = $phpunit . ' --tap %s -c "%s" ' . $this->coverage . $this->path; - $success = $this->phpci->executeCommand($cmd, $this->args, $this->phpci->buildPath . $configPath); - - if ($this->runFrom) { - chdir($curdir); - } - - return $success; + $buildPath = $this->build->getBuildPath() . DIRECTORY_SEPARATOR; + if ($runFrom) { + $originalPath = getcwd(); + // Change the directory + chdir($buildPath . $runFrom); } - } - /** - * Run the PHPUnit tests in a specific directory or array of directories. - * @param $directory - * @return bool|mixed - */ - protected function runDir($directory) - { - if (is_array($directory)) { - return $this->recurseArg($directory, array($this, "runDir")); - } else { - $curdir = getcwd(); - chdir($this->phpci->buildPath); + // Save the results into a json file + $jsonFile = tempnam($this->phpci->buildPath, 'jLog_'); + $options->addArgument('log-json', $jsonFile); - $phpunit = $this->phpci->findBinary('phpunit'); + // Removes any current configurations files + $options->removeArgument('configuration'); + // Only the add the configuration file been passed + $options->addArgument('configuration', $buildPath . $configFile); - $cmd = $phpunit . ' --tap %s "%s"'; - $success = $this->phpci->executeCommand($cmd, $this->args, $this->phpci->buildPath . $directory); - chdir($curdir); - return $success; + $arguments = $this->phpci->interpolate($options->buildArgumentString()); + $cmd = $this->phpci->findBinary('phpunit') . ' %s %s'; + $success = $this->phpci->executeCommand($cmd, $arguments, $options->getTestsPath()); + + if (!empty($originalPath)) { + // Change to che original path + chdir($originalPath); } - } - /** - * @param $array - * @param $callable - * @return bool|mixed - */ - protected function recurseArg($array, $callable) - { - $success = true; - foreach ($array as $subItem) { - $success &= call_user_func($callable, $subItem); - } + $this->processResults($jsonFile); + return $success; } + + /** + * Saves the test results + * + * @param string $jsonFile + * + * @throws \Exception If the failed to parse the JSON file + */ + protected function processResults($jsonFile) + { + if (is_file($jsonFile)) { + $parser = new PhpUnitResult($jsonFile, $this->build->getBuildPath()); + + $this->build->storeMeta('phpunit-data', $parser->parse()->getResults()); + $this->build->storeMeta('phpunit-errors', $parser->getFailures()); + + foreach ($parser->getErrors() as $error) { + $severity = $error['severity'] == $parser::SEVERITY_ERROR ? BuildError::SEVERITY_CRITICAL : BuildError::SEVERITY_HIGH; + $this->build->reportError( + $this->phpci, 'php_unit', $error['message'], $severity, $error['file'], $error['line'] + ); + } + + } else { + throw new \Exception('JSON output file does not exist: ' . $jsonFile); + } + } } diff --git a/PHPCI/Plugin/Util/PhpUnitResult.php b/PHPCI/Plugin/Util/PhpUnitResult.php new file mode 100644 index 00000000..3222f297 --- /dev/null +++ b/PHPCI/Plugin/Util/PhpUnitResult.php @@ -0,0 +1,230 @@ + + * @package PHPCI + * @subpackage Plugin + */ +class PhpUnitResult +{ + const EVENT_TEST = 'test'; + const EVENT_TEST_START = 'testStart'; + const EVENT_SUITE_START = 'suiteStart'; + + const SEVERITY_PASS = 'success'; + const SEVERITY_FAIL = 'fail'; + const SEVERITY_ERROR = 'error'; + const SEVERITY_SKIPPED = 'skipped'; + + protected $options; + protected $arguments = array(); + protected $results; + protected $failures = 0; + protected $errors = array(); + + public function __construct($outputFile, $buildPath = '') + { + $this->outputFile = $outputFile; + $this->buildPath = $buildPath; + } + + /** + * Parse the results + * + * @return $this + * @throws \Exception If fails to parse the output + */ + public function parse() + { + $rawResults = file_get_contents($this->outputFile); + if (empty($rawResults)) { + throw new \Exception('No test executed.'); + } + if ($rawResults[0] == '{') { + $fixedJson = '[' . str_replace('}{', '},{', $rawResults) . ']'; + $events = json_decode($fixedJson, true); + } else { + $events = json_decode($rawResults, true); + } + + // Reset the parsing variables + $this->results = array(); + $this->errors = array(); + $this->failures = 0; + + if (is_array($events)) { + foreach ($events as $event) { + if ($event['event'] == self::EVENT_TEST) { + $this->results[] = $this->parseEvent($event); + } + } + } else { + throw new \Exception('Failed to parse the JSON output.'); + } + + return $this; + } + + /** + * Parse a test event + * + * @param array $event + * + * @return string[] + */ + protected function parseEvent($event) + { + list($pass, $severity) = $this->getStatus($event); + + $data = array( + 'pass' => $pass, + 'severity' => $severity, + 'message' => $this->buildMessage($event), + 'trace' => $pass ? array() : $this->buildTrace($event), + 'output' => $event['output'], + ); + + if (!$pass) { + $this->failures++; + $this->addError($data, $event); + } + + return $data; + } + + /** + * Build the status of the event + * + * @param $event + * + * @return mixed[bool,string] - The pass and severity flags + * @throws \Exception + */ + protected function getStatus($event) + { + $status = $event['status']; + switch ($status) { + case 'fail': + $pass = false; + $severity = self::SEVERITY_FAIL; + break; + case 'error': + if (strpos($event['message'], 'Skipped') === 0 || strpos($event['message'], 'Incomplete') === 0) { + $pass = true; + $severity = self::SEVERITY_SKIPPED; + } else { + $pass = false; + $severity = self::SEVERITY_ERROR; + } + break; + case 'pass': + $pass = true; + $severity = self::SEVERITY_PASS; + break; + default: + throw new \Exception("Unexpected PHPUnit test status: {$status}"); + break; + } + + return array($pass, $severity); + } + + /** + * Build the message string for an event + * + * @param array $event + * + * @return string + */ + protected function buildMessage($event) + { + $message = $event['test']; + + if ($event['message']) { + $message .= PHP_EOL . $event ['message']; + } + + return $message; + } + + /** + * Build a string base trace of the failure + * + * @param array $event + * + * @return string[] + */ + protected function buildTrace($event) + { + $formattedTrace = array(); + + if (!empty($event['trace'])) { + foreach ($event['trace'] as $step){ + $line = str_replace($this->buildPath, '', $step['file']) . ':' . $step['line']; + $formattedTrace[] = $line; + } + } + + return $formattedTrace; + } + + /** + * Saves additional info for a failing test + * + * @param array $data + * @param array $event + */ + protected function addError($data, $event) + { + $firstTrace = end($event['trace']); + reset($event['trace']); + + $this->errors[] = array( + 'message' => $data['message'], + 'severity' => $data['severity'], + 'file' => str_replace($this->buildPath, '', $firstTrace['file']), + 'line' => $firstTrace['line'], + ); + } + + /** + * Get the parse results + * + * @return string[] + */ + public function getResults() + { + return $this->results; + } + + /** + * Get the total number of failing tests + * + * @return int + */ + public function getFailures() + { + return $this->failures; + } + + /** + * Get the tests with failing status + * + * @return string[] + */ + public function getErrors() + { + return $this->errors; + } +} diff --git a/PHPCI/View/Build/errors.phtml b/PHPCI/View/Build/errors.phtml index 04ab9c25..a8e70775 100644 --- a/PHPCI/View/Build/errors.phtml +++ b/PHPCI/View/Build/errors.phtml @@ -3,7 +3,7 @@ use PHPCI\Helper\Lang; $linkTemplate = $build->getFileLinkTemplate(); -/** @var \PHPCI\Model\BuildError $error */ +/** @var \PHPCI\Model\BuildError[] $errors */ foreach ($errors as $error): $link = str_replace('{FILE}', $error->getFile(), $linkTemplate); @@ -30,8 +30,8 @@ foreach ($errors as $error): ?> - getMessage(); ?> + getMessage(); ?> - \ No newline at end of file + diff --git a/PHPCI/View/layout.phtml b/PHPCI/View/layout.phtml index 1960aff6..9f606833 100644 --- a/PHPCI/View/layout.phtml +++ b/PHPCI/View/layout.phtml @@ -18,7 +18,11 @@ - +