Test.php 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. <?php
  2. /**
  3. * Lithium: the most rad php framework
  4. *
  5. * @copyright Copyright 2013, Union of RAD (http://union-of-rad.org)
  6. * @license http://opensource.org/licenses/bsd-license.php The BSD License
  7. */
  8. namespace lithium\console\command;
  9. use lithium\core\Libraries;
  10. use lithium\test\Dispatcher;
  11. use lithium\test\Unit;
  12. /**
  13. * Runs a given set of tests and outputs the results.
  14. *
  15. * @see lithium\test
  16. */
  17. class Test extends \lithium\console\Command {
  18. /**
  19. * Used as the exit code for errors where no test was mapped to file.
  20. */
  21. const EXIT_NO_TEST = 4;
  22. /**
  23. * List of filters to apply before/during/after test run, separated by commas.
  24. *
  25. * For example:
  26. * {{{
  27. * lithium test lithium/tests/cases/core/ObjectTest.php --filters=Coverage
  28. * lithium test lithium/tests/cases/core/ObjectTest.php --filters=Coverage,Profiler
  29. * }}}
  30. *
  31. * @var string Name of a filter or a comma separated list of filter names. Builtin filters:
  32. * - `Affected`: Adds tests to the run affected by the classes covered by current tests.
  33. * - `Complexity`: Calculates the cyclomatic complexity of class methods, and shows
  34. * worst-offenders and statistics.
  35. * - `Coverage`: Runs code coverage analysis for the executed tests.
  36. * - `Profiler`: Tracks timing and memory usage information for each test method.
  37. */
  38. public $filters;
  39. /**
  40. * Format to use for rendering results. Any other format than `txt` will
  41. * cause the command to enter quiet mode, surpressing headers and any other
  42. * decoration.
  43. *
  44. * @var string Either `txt` or `json`.
  45. */
  46. public $format = 'txt';
  47. /**
  48. * Enable verbose output especially for the `txt` format.
  49. *
  50. * @var boolean
  51. */
  52. public $verbose = false;
  53. /**
  54. * Enable plain mode to prevent any headers or similar decoration being output.
  55. * Good for command calls embedded into other scripts.
  56. *
  57. * @var boolean
  58. */
  59. public $plain = false;
  60. /**
  61. * An array of closures, mapped by type, which are set up to handle different test output
  62. * formats.
  63. *
  64. * @var array
  65. */
  66. protected $_handlers = array();
  67. /**
  68. * Initializes the output handlers.
  69. *
  70. * @see lithium\console\command\Test::$_handlers
  71. * @return void
  72. */
  73. protected function _init() {
  74. parent::_init();
  75. $command = $this;
  76. $this->_handlers += array(
  77. 'txt' => function($runner, $path) use ($command) {
  78. if (!$command->plain) {
  79. $command->header('Test');
  80. $command->out(null, 1);
  81. }
  82. $colorize = function($result) {
  83. switch (trim($result)) {
  84. case '.':
  85. return $result;
  86. case 'pass':
  87. return "{:green}{$result}{:end}";
  88. case 'F':
  89. case 'fail':
  90. return "{:red}{$result}{:end}";
  91. case 'E':
  92. case 'exception':
  93. return "{:purple}{$result}{:end}";
  94. case 'S':
  95. case 'skip':
  96. return "{:cyan}{$result}{:end}";
  97. default:
  98. return "{:yellow}{$result}{:end}";
  99. }
  100. };
  101. if ($command->verbose) {
  102. $reporter = function($result) use ($command, $colorize) {
  103. $command->out(sprintf(
  104. '[%s] on line %4s in %s::%s()',
  105. $colorize(sprintf('%9s', $result['result'])),
  106. isset($result['line']) ? $result['line'] : '??',
  107. isset($result['class']) ? $result['class'] : '??',
  108. isset($result['method']) ? $result['method'] : '??'
  109. ));
  110. };
  111. } else {
  112. $i = 0;
  113. $columns = 60;
  114. $reporter = function($result) use ($command, &$i, $columns, $colorize) {
  115. $shorten = array('fail', 'skip', 'exception');
  116. if ($result['result'] === 'pass') {
  117. $symbol = '.';
  118. } elseif (in_array($result['result'], $shorten)) {
  119. $symbol = strtoupper($result['result'][0]);
  120. } else {
  121. $symbol = '?';
  122. }
  123. $command->out($colorize($symbol), false);
  124. $i++;
  125. if ($i % $columns === 0) {
  126. $command->out();
  127. }
  128. };
  129. }
  130. $report = $runner(compact('reporter'));
  131. if (!$command->plain) {
  132. $stats = $report->stats();
  133. $command->out(null, 2);
  134. $command->out($report->render('result', $stats));
  135. $command->out($report->render('errors', $stats));
  136. if ($command->verbose) {
  137. $command->out($report->render('skips', $stats));
  138. }
  139. foreach ($report->filters() as $filter => $options) {
  140. $data = $report->results['filters'][$filter];
  141. $command->out($report->render($options['name'], compact('data')));
  142. }
  143. }
  144. return $report;
  145. },
  146. 'json' => function($runner, $path) use ($command) {
  147. $report = $runner();
  148. if ($results = $report->filters()) {
  149. $filters = array();
  150. foreach ($results as $filter => $options) {
  151. $filters[$options['name']] = $report->results['filters'][$filter];
  152. }
  153. }
  154. $command->out($report->render('stats', $report->stats() + compact('filters')));
  155. return $report;
  156. }
  157. );
  158. }
  159. /**
  160. * Runs tests given a path to a directory or file containing tests. The path to the
  161. * test(s) may be absolute or relative to the current working directory.
  162. *
  163. * {{{
  164. * li3 test lithium/tests/cases/core/ObjectTest.php
  165. * li3 test lithium/tests/cases/core
  166. * }}}
  167. *
  168. * If you are in the working directory of an application or plugin and wish to run all tests,
  169. * simply execute the following:
  170. *
  171. * {{{
  172. * li3 test tests/cases
  173. * }}}
  174. *
  175. * If you are in the working directory of an application and wish to run a plugin, execute one
  176. * of the following:
  177. *
  178. * {{{
  179. * li3 test libraries/<plugin>/tests/cases
  180. * li3 test <plugin>/tests/cases
  181. * }}}
  182. *
  183. *
  184. * This will run `<library>/tests/cases/<package>/<class>Test.php`:
  185. *
  186. * {{{
  187. * li3 test <library>/<package>/<class>.php
  188. * }}}
  189. *
  190. * @param string $path Absolute or relative path to tests or a file which
  191. * corresponding test should be run.
  192. * @return boolean Will exit with status `1` if one or more tests failed otherwise with `0`.
  193. */
  194. public function run($path = null) {
  195. if (!$path = $this->_path($path)) {
  196. return false;
  197. }
  198. if (!preg_match('/(tests|Test\.php)/', $path)) {
  199. if (!$path = Unit::get($path)) {
  200. $this->error('Cannot map path to test path.');
  201. return static::EXIT_NO_TEST;
  202. }
  203. }
  204. $handlers = $this->_handlers;
  205. if (!isset($handlers[$this->format]) || !is_callable($handlers[$this->format])) {
  206. $this->error(sprintf('No handler for format `%s`... ', $this->format));
  207. return false;
  208. }
  209. $filters = $this->filters ? array_map('trim', explode(',', $this->filters)) : array();
  210. $params = compact('filters') + array('format' => $this->format);
  211. $runner = function($options = array()) use ($path, $params) {
  212. error_reporting(E_ALL | E_STRICT | E_DEPRECATED);
  213. return Dispatcher::run($path, $params + $options);
  214. };
  215. $report = $handlers[$this->format]($runner, $path);
  216. $stats = $report->stats();
  217. return $stats['success'];
  218. }
  219. /**
  220. * Finds a library for given path.
  221. *
  222. * @param string $path Normalized (to slashes) absolute or relative path.
  223. * @return string the name of the library
  224. */
  225. protected function _library($path) {
  226. foreach (Libraries::get() as $name => $library) {
  227. if (strpos($path, $library['path']) !== 0) {
  228. continue;
  229. }
  230. return $name;
  231. }
  232. }
  233. /**
  234. * Validates and gets a fully-namespaced class path from an absolute or
  235. * relative physical path to a directory or file. The final class path may
  236. * be partial in that in doesn't contain the class name.
  237. *
  238. * This method can be thought of the reverse of `Libraries::path()`.
  239. *
  240. * {{{
  241. * lithium/tests/cases/core/ObjectTest.php -> lithium\tests\cases\core\ObjectTest
  242. * lithium/tests/cases/core -> lithium\tests\cases\core
  243. * lithium/core/Object.php -> lithium\core\Object
  244. * lithium/core/ -> lithium\core
  245. * lithium/core -> lithium\core
  246. * }}}
  247. *
  248. * @see lithium\core\Libraries::path()
  249. * @param string $path The directory of or file path to one or more classes.
  250. * @return string Returns a fully-namespaced class path, or `false`, if an error occurs.
  251. */
  252. protected function _path($path) {
  253. $path = rtrim(str_replace('\\', '/', $path), '/');
  254. if (!$path) {
  255. $this->error('Please provide a path to tests.');
  256. return false;
  257. }
  258. if ($path[0] === '/') {
  259. $library = $this->_library($path);
  260. }
  261. if ($path[0] !== '/') {
  262. $libraries = array_reduce(Libraries::get(), function($v, $w) {
  263. $v[] = basename($w['path']);
  264. return $v;
  265. });
  266. $library = basename($this->request->env('working'));
  267. $parts = explode('/', str_replace("../", "", $path));
  268. $plugin = array_shift($parts);
  269. if ($plugin === 'libraries') {
  270. $plugin = array_shift($parts);
  271. }
  272. if (in_array($plugin, $libraries)) {
  273. $library = $plugin;
  274. $path = join('/', $parts);
  275. }
  276. }
  277. if (empty($library)) {
  278. $this->error("No library found in path `{$path}`.");
  279. return false;
  280. }
  281. if (!$config = Libraries::get($library)) {
  282. $this->error("Library `{$library}` does not exist.");
  283. return false;
  284. }
  285. $path = str_replace($config['path'], null, $path);
  286. $realpath = $config['path'] . '/' . $path;
  287. if (!realpath($realpath)) {
  288. $this->error("Path `{$realpath}` not found.");
  289. return false;
  290. }
  291. $class = str_replace(".php", "", str_replace('/', '\\', ltrim($path, '/')));
  292. return $config['prefix'] . $class;
  293. }
  294. }
  295. ?>