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..8bb9420a --- /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 argument in a single string + */ + preg_match_all('/--([a-z\-]+)\s?("?[^-]{2}[^"]*"?)?/', (string)$rawArgs, $argsMatch); + + if (!empty($argsMatch) && sizeof($argsMatch) > 2) { + foreach ($argsMatch[1] as $index => $argName) { + $this->addArgument($argName, $argsMatch[2][$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/PhpUnitV2.php b/PHPCI/Plugin/PhpUnitV2.php new file mode 100644 index 00000000..e7ac13b5 --- /dev/null +++ b/PHPCI/Plugin/PhpUnitV2.php @@ -0,0 +1,207 @@ + + * @package PHPCI + * @subpackage Plugins + */ +class PhpUnitV2 implements PHPCI\Plugin, PHPCI\ZeroConfigPlugin +{ + protected $phpci; + protected $build; + + /** @var string[] Raw options from the PHPCI config file */ + protected $options = array(); + + /** + * 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 + */ + public function __construct(Builder $phpci, Build $build, array $options = array()) + { + $this->phpci = $phpci; + $this->build = $build; + $this->options = new PhpUnitOptions($options); + } + + /** + * Check if the plugin can be executed without any configurations + * + * @param $stage + * @param Builder $builder + * @param Build $build + * + * @return bool + */ + public static function canExecute($stage, Builder $builder, Build $build) + { + if ($stage == 'test' && !is_null(PhpUnitOptions::findConfigFile($build->getBuildPath()))) { + return true; + } + + return false; + } + + /** + * Runs PHP Unit tests in a specified directory, optionally using specified config file(s). + */ + public function execute() + { + $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 = array(); + + // 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); + } + } + } + + return !in_array(false, $success); + } + + /** + * 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; + + $buildPath = $this->build->getBuildPath() . DIRECTORY_SEPARATOR; + + $currentPath = getcwd(); + // Change the directory + chdir($buildPath); + + // Save the results into a json file + $jsonFile = tempnam(dirname($buildPath), 'jLog_'); + $options->addArgument('log-json', $jsonFile); + + // 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 $configFile + * + * @return bool|mixed + */ + protected function runConfigFile($configFile) + { + $options = clone $this->options; + $runFrom = $options->getRunFrom(); + + $buildPath = $this->build->getBuildPath() . DIRECTORY_SEPARATOR; + if ($runFrom) { + $originalPath = getcwd(); + // Change the directory + chdir($buildPath . $runFrom); + } + + // Save the results into a json file + $jsonFile = tempnam($this->phpci->buildPath, 'jLog_'); + $options->addArgument('log-json', $jsonFile); + + // Removes any current configurations files + $options->removeArgument('configuration'); + // Only the add the configuration file been passed + $options->addArgument('configuration', $buildPath . $configFile); + + $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); + } + + $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..e359708e --- /dev/null +++ b/PHPCI/Plugin/Util/PhpUnitResult.php @@ -0,0 +1,227 @@ + + * @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 ($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 @@ - +