PageRenderTime 57ms CodeModel.GetById 28ms RepoModel.GetById 0ms app.codeStats 0ms

/lib/test/TestRunnerWithBaseline.php

https://gitlab.com/ElvisAns/tiki
PHP | 459 lines | 368 code | 68 blank | 23 comment | 49 complexity | 507e6550a6d1c944a6d5bfb263fc2553 MD5 | raw file
  1. <?php
  2. /**
  3. * Created by JetBrains PhpStorm.
  4. * User: alain_desilets
  5. * Date: 2013-10-02
  6. * Time: 2:12 PM
  7. * To change this template use File | Settings | File Templates.
  8. *
  9. * This class is used to run phpunit tests and compare the list of failures
  10. * and errors to those of a benchmark "normal" run.
  11. *
  12. * Use this class in situations where it's not practical for everyone to
  13. * keep all the tests "in the green" at all time, and to only commit code
  14. * that doesn't break any tests.
  15. *
  16. * With this class, you can tell if you have broken tests that were working
  17. * previously, or if you have fixed tests that were broken before.
  18. */
  19. require_once(__DIR__ . '/../debug/Tracer.php');
  20. class TestRunnerWithBaseline
  21. {
  22. private $baseline_log_fpath;
  23. private $current_log_fpath;
  24. private $output_fpath;
  25. private $last_test_started = null;
  26. private $logname_stem = 'phpunit-log';
  27. public $action = 'run'; // run|update_baseline
  28. public $phpunit_options = '';
  29. public $help = false;
  30. public $filter = '';
  31. public $diffs;
  32. public function __construct($baseline_log_fpath = null, $current_log_fpath = null, $output_fpath = null)
  33. {
  34. $this->baseline_log_fpath = $baseline_log_fpath;
  35. $this->current_log_fpath = $current_log_fpath;
  36. $this->output_fpath = $output_fpath;
  37. }
  38. public function run()
  39. {
  40. global $tracer;
  41. $this->configFromCmdlineOptions();
  42. if ($this->help) {
  43. $this->usage();
  44. }
  45. $this->runTests();
  46. $this->printDiffsWithBaseline();
  47. if ($this->action === 'update_baseline') {
  48. $this->saveCurrentLogAsBaseline();
  49. }
  50. }
  51. public function runTests()
  52. {
  53. global $tracer;
  54. $cmd_line = "../../bin/phpunit --verbose";
  55. if ($this->phpunit_options != '') {
  56. $cmd_line = "$cmd_line " . $this->phpunit_options;
  57. }
  58. if ($this->filter != '') {
  59. $cmd_line = "$cmd_line --filter " . $this->filter;
  60. }
  61. $cmd_line = 'php ' . $cmd_line . " --log-json \"" . $this->logpath_current() . "\" .";
  62. $this->do_echo("
  63. ********************************************************************
  64. *
  65. * Executing phpunit as:
  66. *
  67. * $cmd_line
  68. *
  69. ********************************************************************
  70. ");
  71. if ($this->output_fpath == null) {
  72. system($cmd_line);
  73. } else {
  74. $phpunit_output = [];
  75. exec($cmd_line, $phpunit_output);
  76. $this->do_echo(implode("\n", $phpunit_output));
  77. }
  78. }
  79. public function printDiffsWithBaseline()
  80. {
  81. global $tracer;
  82. $this->doEcho("\n\nChecking for differences with baseline test logs...\n\n");
  83. $baseline_issues;
  84. if (file_exists($this->logpath_baseline())) {
  85. $baseline_issues = $this->readLogFile($this->logpath_baseline());
  86. } else {
  87. $this->doEcho("=== WARNING: No baseline file exists. Assuming empty baseline.\n\n");
  88. $baseline_issues = $this->makeEmptyIssuesList();
  89. }
  90. $current_issues = $this->readLogFile($this->logpath_current());
  91. $this->diffs = $this->compareTwoTestRuns($baseline_issues, $current_issues);
  92. $nb_failures_introduced = count($this->diffs['failures_introduced']);
  93. $nb_failures_fixed = count($this->diffs['failures_fixed']);
  94. $nb_errors_introduced = count($this->diffs['errors_introduced']);
  95. $nb_errors_fixed = count($this->diffs['errors_fixed']);
  96. $total_diffs =
  97. $nb_failures_introduced + $nb_errors_introduced +
  98. $nb_failures_fixed + $nb_errors_fixed;
  99. if ($total_diffs > 0) {
  100. $this->doEcho("\n\nThere were $total_diffs differences with baseline.\n
  101. Below is a list of tests that differ from the baseline.
  102. See above details about each error or failure.
  103. ");
  104. if ($nb_failures_introduced > 0) {
  105. $this->doEcho("\nNb of new FAILURES: $nb_failures_introduced:\n");
  106. foreach ($this->diffs['failures_introduced'] as $an_issue) {
  107. $this->doEcho(" $an_issue\n");
  108. }
  109. }
  110. if ($nb_errors_introduced > 0) {
  111. $this->doEcho("\nNb of new ERRORS: $nb_errors_introduced:\n");
  112. foreach ($this->diffs['errors_introduced'] as $an_issue) {
  113. $this->doEcho(" $an_issue\n");
  114. }
  115. }
  116. if ($nb_failures_fixed > 0) {
  117. $this->doEcho("\nNb of newly FIXED FAILURES: $nb_failures_fixed:\n");
  118. foreach ($this->diffs['failures_fixed'] as $an_issue) {
  119. $this->doEcho(" $an_issue\n");
  120. }
  121. }
  122. if ($nb_errors_fixed > 0) {
  123. $this->doEcho("\nNb of newly FIXED ERRORS: $nb_errors_fixed:\n");
  124. foreach ($this->diffs['errors_fixed'] as $an_issue) {
  125. $this->doEcho(" $an_issue\n");
  126. }
  127. }
  128. } else {
  129. $this->doEcho("\n\nNo differences with baseline run. All is \"normal\".\n\n");
  130. }
  131. $this->doEcho("\n\n");
  132. }
  133. public function logpathCurrent()
  134. {
  135. $path = __DIR__ . DIRECTORY_SEPARATOR . $this->logname_stem . ".current.json";
  136. if ($this->current_log_fpath != null) {
  137. $path = $this->current_log_fpath;
  138. }
  139. return $path;
  140. }
  141. public function logpathBaseline()
  142. {
  143. $path = __DIR__ . DIRECTORY_SEPARATOR . $this->logname_stem . ".baseline.json";
  144. if ($this->baseline_log_fpath != null) {
  145. $path = $this->baseline_log_fpath;
  146. }
  147. return $path;
  148. }
  149. public function askIfWantToCreateBaseline()
  150. {
  151. $answer = $this->promptFor(
  152. "There is no baseline log. Would you like to log current failures and errors as the baseline?",
  153. ['y', 'n']
  154. );
  155. if ($answer === 'y') {
  156. $this->saveCurrentLogAsBaseline();
  157. }
  158. }
  159. public function processPhpunitLogData($log_data)
  160. {
  161. global $tracer;
  162. $issues =
  163. [
  164. 'errors' => [],
  165. 'failures' => [],
  166. 'pass' => []
  167. ];
  168. foreach ($log_data as $log_entry) {
  169. $event = $log_entry['event'] ?? '';
  170. if ($event !== 'testStart' && $event !== 'test') {
  171. continue;
  172. }
  173. $test = $log_entry['test'] ?? '';
  174. if ($event === 'testStart') {
  175. $this->last_test_started = $test;
  176. continue;
  177. }
  178. $this->last_test_started = null;
  179. /* For some reason, sometimes an event=test entry does not have
  180. a 'status' field.
  181. Whenever that happens, it seems to be a sign of an error.
  182. */
  183. $status = $log_entry['status'] ?? 'fail';
  184. if ($status === 'fail') {
  185. array_push($issues['failures'], $test);
  186. } elseif ($status === 'error') {
  187. array_push($issues['errors'], $test);
  188. } elseif ($status === 'pass') {
  189. array_push($issues['pass'], $test);
  190. }
  191. }
  192. /* If a test was started by never ended, flag it as a failure */
  193. if ($this->last_test_started != null) {
  194. if (! in_array($this->last_test_started, $issues['failures'])) {
  195. array_push($issues['failures'], $this->last_test_started);
  196. }
  197. }
  198. return $issues;
  199. }
  200. public function compareTwoTestRuns($baseline_issues, $current_issues)
  201. {
  202. global $tracer;
  203. $diffs = ['failures_introduced' => [], 'failures_fixed' => [],
  204. 'errors_introduced' => [], 'errors_fixed' => []];
  205. $current_failures = $current_issues['failures'];
  206. $current_pass = $current_issues['pass'];
  207. $baseline_failures = $baseline_issues['failures'];
  208. $baseline_errors = $baseline_issues['errors'];
  209. foreach ($baseline_failures as $a_baseline_failure) {
  210. if (in_array($a_baseline_failure, $current_pass)) {
  211. array_push($diffs['failures_fixed'], $a_baseline_failure);
  212. }
  213. }
  214. foreach ($current_failures as $a_current_failure) {
  215. if (! in_array($a_current_failure, $baseline_failures) && ! in_array($a_current_failure, $baseline_errors)) {
  216. array_push($diffs['failures_introduced'], $a_current_failure);
  217. }
  218. }
  219. $baseline_errors = $baseline_issues['errors'];
  220. $current_errors = $current_issues['errors'];
  221. foreach ($baseline_errors as $a_baseline_error) {
  222. if (in_array($a_baseline_error, $current_pass)) {
  223. array_push($diffs['errors_fixed'], $a_baseline_error);
  224. }
  225. }
  226. foreach ($current_errors as $a_current_error) {
  227. if (! in_array($a_current_error, $baseline_errors) && ! in_array($a_current_error, $baseline_failures)) {
  228. array_push($diffs['errors_introduced'], $a_current_error);
  229. }
  230. }
  231. return $diffs;
  232. }
  233. public function saveCurrentLogAsBaseline()
  234. {
  235. if ($this->totalNewIssuesFound() > 0) {
  236. $answer = $this->promptFor(
  237. "Some new failures and/or errors were introduced (see above for details).\n\nAre you SURE you want to save the current run as a baseline?\n",
  238. ['y', 'n']
  239. );
  240. if ($answer === 'n') {
  241. $this->doEcho("\nThe current run was NOT saved as the new baseline.\n");
  242. return;
  243. }
  244. }
  245. $this->doEcho("\n\nSaving current phpunit log as the baseline.\n");
  246. copy($this->logpathCurrent(), $this->logpathBaseline());
  247. }
  248. public function promptFor($prompt, $eligible_answers)
  249. {
  250. $prompt = "\n\n$prompt (" . implode('|', $eligible_answers) . ")\n> ";
  251. $answer = null;
  252. while ($answer == null) {
  253. echo $prompt;
  254. $tentative_answer = rtrim(fgets(STDIN));
  255. if (in_array($tentative_answer, $eligible_answers)) {
  256. $answer = $tentative_answer;
  257. } else {
  258. $prompt = "\n\nSorry, '$tentative_answer' is not a valid answer.$prompt";
  259. }
  260. }
  261. $this->doEcho("\$answer='$answer'\n'");
  262. return $answer;
  263. }
  264. public function readLogFile($log_file_path)
  265. {
  266. global $tracer;
  267. $json_string = file_get_contents($log_file_path);
  268. // The json string is actually a sequence of json arrays, but the
  269. // sequence itself is not wrapped inside an array.
  270. //
  271. // Wrap all the json arrays into one before parsing the json.
  272. //
  273. $json_string = preg_replace('/}\s*{/', "},\n {", $json_string);
  274. $json_string = "[\n $json_string\n]";
  275. $json_decoded = json_decode($json_string, true);
  276. return $this->processPhpunitLogData($json_decoded);
  277. }
  278. public function configFromCmdlineOptions()
  279. {
  280. global $argv, $tracer;
  281. $options = getopt('', ['action:', 'phpunit-options:', 'filter:', 'help']);
  282. $options = $this->validateCmdlineOptions($options);
  283. if (isset($options['help'])) {
  284. $this->help = true;
  285. }
  286. if (isset($options['action'])) {
  287. $this->action = $options['action'];
  288. }
  289. if (isset($options['phpunit-options'])) {
  290. $this->phpunit_options = $options['phpunit-options'];
  291. }
  292. if (isset($options['filter'])) {
  293. $this->filter = $options['filter'];
  294. }
  295. }
  296. public function validateCmdlineOptions($options)
  297. {
  298. global $tracer;
  299. $action = $options['action'] ?? '';
  300. if ($action === 'update_baseline' && isset($options['phpunit-options'])) {
  301. $this->usage("Cannot specify --phpunit-options with --action=update_baseline.");
  302. }
  303. $phpunit_options = $options['phpunit-options'] ?? '';
  304. if (preg_match('/--log-json/', $phpunit_options)) {
  305. $this->usage("You cannot specify '--log-json' option in the '--phpunit-options' option.");
  306. }
  307. if (preg_match('/--filter/', $phpunit_options)) {
  308. $this->usage("You cannot specify '--filter' option in the '--phpunit-options' option. Instead, the --filter option of {$GLOBALS['argv'][0]} directely (i.e., '{$GLOBALS['argv'][0]} --filter pattern')");
  309. }
  310. return $options;
  311. }
  312. public function usage($error_message = null)
  313. {
  314. global $argv;
  315. $script_name = $argv[0];
  316. $help = "php $script_name options
  317. Run phpunit tests, and compare the list of errors and failures against
  318. a baseline. Only report tests that have either started or stopped
  319. failing.
  320. Options
  321. --action run|update_baseline (Default: run)
  322. run:
  323. Run the tests and report diffs from baseline.
  324. update_baseline
  325. Run ALL the tests, and save the list of generated failures
  326. and errors as the new baseline.
  327. --filter pattern
  328. Only run the test methods whose names match the pattern.
  329. --phpunit-options options (Default: '')
  330. Command line options to be passed to phpunit.
  331. Those are ignored when --action=update_baseline.
  332. Also, you cannot specify a --log-json option in those, as that would
  333. interfere with the script's ability to log test results for comparison
  334. against the baseline.
  335. ";
  336. if ($error_message != null) {
  337. $help = "ERROR: $error_message\n\n$help";
  338. }
  339. exit("\n$help");
  340. }
  341. public function makeEmptyIssuesList()
  342. {
  343. return ['pass' => [], 'failures' => [], 'errors' => []];
  344. }
  345. public function totalNewIssuesFound()
  346. {
  347. global $tracer;
  348. $total = count($this->diffs['errors_introduced']) + count($this->diffs['failures_introduced']);
  349. $tracer->trace('total_new_issues_found', "** Returning \$total=$total");
  350. return $total;
  351. }
  352. private function doEcho($message)
  353. {
  354. if ($this->output_fpath == null) {
  355. echo($message);
  356. } else {
  357. $fh_output = fopen($this->output_fpath, 'a');
  358. fwrite($fh_output, $message);
  359. fclose($fh_output);
  360. }
  361. }
  362. }