MDL-54628 behat: Show parallel run command with proper rerun option
[moodle.git] / admin / tool / behat / cli / run.php
blob4787e843b23a4ba42c35491e880916c9a5e62c0c
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17 /**
18 * Wrapper to run previously set-up behat tests in parallel.
20 * @package tool_behat
21 * @copyright 2014 NetSpot Pty Ltd
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 if (isset($_SERVER['REMOTE_ADDR'])) {
26 die(); // No access from web!
29 define('CLI_SCRIPT', true);
30 define('ABORT_AFTER_CONFIG', true);
31 define('CACHE_DISABLE_ALL', true);
32 define('NO_OUTPUT_BUFFERING', true);
34 require_once(__DIR__ .'/../../../../config.php');
35 require_once(__DIR__.'/../../../../lib/clilib.php');
36 require_once(__DIR__.'/../../../../lib/behat/lib.php');
37 require_once(__DIR__.'/../../../../lib/behat/classes/behat_command.php');
38 require_once(__DIR__.'/../../../../lib/behat/classes/behat_config_manager.php');
40 error_reporting(E_ALL | E_STRICT);
41 ini_set('display_errors', '1');
42 ini_set('log_errors', '1');
44 list($options, $unrecognised) = cli_get_params(
45 array(
46 'stop-on-failure' => 0,
47 'verbose' => false,
48 'replace' => false,
49 'help' => false,
50 'tags' => '',
51 'profile' => '',
52 'feature' => '',
53 'suite' => '',
54 'fromrun' => 1,
55 'torun' => 0,
56 'single-run' => false,
57 'rerun' => 0,
59 array(
60 'h' => 'help',
61 't' => 'tags',
62 'p' => 'profile',
63 's' => 'single-run',
67 // Checking run.php CLI script usage.
68 $help = "
69 Behat utilities to run behat tests in parallel
71 Usage:
72 php run.php [--BEHAT_OPTION=\"value\"] [--feature=\"value\"] [--replace] [--fromrun=value --torun=value] [--help]
74 Options:
75 --BEHAT_OPTION Any combination of behat option specified in http://behat.readthedocs.org/en/v2.5/guides/6.cli.html
76 --feature Only execute specified feature file (Absolute path of feature file).
77 --suite Specified theme scenarios will be executed.
78 --replace Replace args string with run process number, useful for output.
79 --fromrun Execute run starting from (Used for parallel runs on different vms)
80 --torun Execute run till (Used for parallel runs on different vms)
81 --rerun Re-run scenarios that failed during last execution.
83 -h, --help Print out this help
85 Example from Moodle root directory:
86 \$ php admin/tool/behat/cli/run.php --tags=\"@javascript\"
88 More info in http://docs.moodle.org/dev/Acceptance_testing#Running_tests
91 if (!empty($options['help'])) {
92 echo $help;
93 exit(0);
96 $parallelrun = behat_config_manager::get_parallel_test_runs($options['fromrun']);
98 // Default torun is maximum parallel runs.
99 if (empty($options['torun'])) {
100 $options['torun'] = $parallelrun;
103 // Capture signals and ensure we clean symlinks.
104 if (extension_loaded('pcntl')) {
105 $disabled = explode(',', ini_get('disable_functions'));
106 if (!in_array('pcntl_signal', $disabled)) {
107 pcntl_signal(SIGTERM, "signal_handler");
108 pcntl_signal(SIGINT, "signal_handler");
112 $time = microtime(true);
113 array_walk($unrecognised, function (&$v) {
114 if ($x = preg_filter("#^(-+\w+)=(.+)#", "\$1=\"\$2\"", $v)) {
115 $v = $x;
116 } else if (!preg_match("#^-#", $v)) {
117 $v = escapeshellarg($v);
120 $extraopts = $unrecognised;
122 if ($options['profile']) {
123 $profile = $options['profile'];
125 // If profile passed is not set, then exit.
126 if (!isset($CFG->behat_config[$profile]) && !isset($CFG->behat_profiles[$profile]) &&
127 !(isset($options['replace']) && (strpos($options['profile'], $options['replace']) >= 0 ))) {
128 echo "Invalid profile passed: " . $profile . PHP_EOL;
129 exit(1);
132 $extraopts['profile'] = '--profile="' . $profile . '"';
133 // By default, profile tags will be used.
134 if (!empty($CFG->behat_config[$profile]['filters']['tags'])) {
135 $tags = $CFG->behat_config[$profile]['filters']['tags'];
139 // Command line tags have precedence (std behat behavior).
140 if ($options['tags']) {
141 $tags = $options['tags'];
142 $extraopts['tags'] = '--tags="' . $tags . '"';
145 // Add suite option if specified.
146 if ($options['suite']) {
147 $extraopts['suite'] = '--suite="' . $options['suite'] . '"';
150 // Feature should be added to last, for behat command.
151 if ($options['feature']) {
152 $extraopts['feature'] = $options['feature'];
153 // Only run 1 process as process.
154 // Feature file is picked from absolute path provided, so no need to check for behat.yml.
155 $options['torun'] = $options['fromrun'];
158 // Set of options to pass to behat.
159 $extraoptstr = implode(' ', $extraopts);
161 // If empty parallelrun then just check with user if it's a run single behat test.
162 if (empty($parallelrun)) {
163 $cwd = getcwd();
164 chdir(__DIR__);
165 $runtestscommand = behat_command::get_behat_command(false, false, true);
166 $runtestscommand .= ' --config ' . behat_config_manager::get_behat_cli_config_filepath();
167 $runtestscommand .= ' ' . $extraoptstr;
168 echo "Running single behat site:" . PHP_EOL;
169 passthru("php $runtestscommand", $code);
170 chdir($cwd);
171 exit($code);
174 // If rerun is passed then ensure we just run the failed processes.
175 $lastfailedstatus = 0;
176 if ($options['rerun']) {
177 $lastfailedstatus = get_last_failed_status($options['fromrun'], $options['torun']);
178 unset($options['rerun']);
179 $extraoptstr .= ' --rerun';
182 $cmds = array();
183 echo "Running " . ($options['torun'] - $options['fromrun'] + 1) . " parallel behat sites:" . PHP_EOL;
185 for ($i = $options['fromrun']; $i <= $options['torun']; $i++) {
186 // Bypass if not failed in last run.
187 if ($lastfailedstatus && !($i & $lastfailedstatus)) {
188 continue;
191 $CFG->behatrunprocess = $i;
193 // Options parameters to be added to each run.
194 $myopts = !empty($options['replace']) ? str_replace($options['replace'], $i, $extraoptstr) : $extraoptstr;
196 $behatcommand = behat_command::get_behat_command(false, false, true);
197 $behatconfigpath = behat_config_manager::get_behat_cli_config_filepath($i);
199 // Command to execute behat run.
200 $cmds[BEHAT_PARALLEL_SITE_NAME . $i] = $behatcommand . ' --config ' . $behatconfigpath . " " . $myopts;
201 echo "[" . BEHAT_PARALLEL_SITE_NAME . $i . "] " . $cmds[BEHAT_PARALLEL_SITE_NAME . $i] . PHP_EOL;
203 // Remove any old last failed status files.
204 $filepath = behat_command::get_last_failed_test_status_file_path($options['fromrun']);
205 if (file_exists($filepath)) {
206 @unlink($filepath);
210 if (empty($cmds)) {
211 echo "No commands to execute " . PHP_EOL;
212 exit(1);
215 // Create site symlink if necessary.
216 if (!behat_config_manager::create_parallel_site_links($options['fromrun'], $options['torun'])) {
217 echo "Check permissions. If on windows, make sure you are running this command as admin" . PHP_EOL;
218 exit(1);
221 // Execute all commands, relative to moodle root directory.
222 $processes = cli_execute_parallel($cmds, __DIR__ . "/../../../../");
223 $stoponfail = empty($options['stop-on-failure']) ? false : true;
225 // Print header.
226 print_process_start_info($processes);
228 // Print combined run o/p from processes.
229 $exitcodes = print_combined_run_output($processes, $stoponfail);
230 $time = round(microtime(true) - $time, 1);
231 echo "Finished in " . gmdate("G\h i\m s\s", $time) . PHP_EOL . PHP_EOL;
233 ksort($exitcodes);
235 // Print exit info from each run.
236 // Status bits contains pass/fail status of parallel runs.
237 $status = 0;
238 foreach ($exitcodes as $name => $exitcode) {
239 if ($exitcode) {
240 $runno = str_replace(BEHAT_PARALLEL_SITE_NAME, '', $name);
241 $status |= (1 << ($runno - 1));
245 // Run finished. Show exit code and output from individual process.
246 $verbose = empty($options['verbose']) ? false : true;
248 // Show exit code from each process, if any process failed.
249 if ($verbose || $status) {
250 // Echo exit codes.
251 echo "Exit codes for each behat run: " . PHP_EOL;
252 foreach ($exitcodes as $run => $exitcode) {
253 echo $run . ": " . $exitcode . PHP_EOL;
256 // Show failed re-run commands.
257 if ($status) {
258 echo "To re-run failed processes, you can use following commands:" . PHP_EOL;
259 foreach ($cmds as $name => $cmd) {
260 if (!empty($exitcodes[$name])) {
261 // Show rerun command only for the failed runs.
262 $runno = str_replace(BEHAT_PARALLEL_SITE_NAME, '', $name);
263 if ((1 << ($runno - 1)) & $status) {
264 $extraopts['fromrun'] = '--fromrun=' . $runno;
265 $extraopts['torun'] = '--torun=' . $runno;
266 $extraopts['rerun'] = '--rerun';
267 $extraoptstr = implode(' ', $extraopts);
269 $myopts = !empty($options['replace']) ? str_replace($options['replace'], $runno, $extraoptstr) : $extraoptstr;
271 $behatcommand = behat_command::get_behat_command(true, true, true);
272 echo "[" . $name . "] " . $behatcommand . ' ' . $myopts . PHP_EOL;
275 // Save information about this failure.
276 $filepath = behat_command::get_last_failed_test_status_file_path($runno);
277 if (!file_put_contents($filepath, $status)) {
278 behat_error(BEHAT_EXITCODE_PERMISSIONS, 'File ' . $filepath . ' can not be created');
283 echo PHP_EOL;
286 print_each_process_info($processes, $verbose, $status);
288 // Remove site symlink if necessary.
289 behat_config_manager::drop_parallel_site_links();
291 exit($status);
294 * Signal handler for terminal exit.
296 * @param int $signal signal number.
298 function signal_handler($signal) {
299 switch ($signal) {
300 case SIGTERM:
301 case SIGKILL:
302 case SIGINT:
303 // Remove site symlink if necessary.
304 behat_config_manager::drop_parallel_site_links();
305 exit(1);
310 * Prints header from the first process.
312 * @param array $processes list of processes to loop though.
314 function print_process_start_info($processes) {
315 $printed = false;
316 // Keep looping though processes, till we get first process o/p.
317 while (!$printed) {
318 usleep(10000);
319 foreach ($processes as $name => $process) {
320 // Exit if any process has stopped.
321 if (!$process->isRunning()) {
322 $printed = true;
323 break;
326 $op = explode(PHP_EOL, $process->getOutput());
327 if (count($op) >= 3) {
328 foreach ($op as $line) {
329 if (trim($line) && (strpos($line, '.') !== 0)) {
330 echo $line . PHP_EOL;
333 $printed = true;
340 * Loop though all processes and print combined o/p
342 * @param array $processes list of processes to loop though.
343 * @param bool $stoponfail Stop all processes and exit if failed.
344 * @return array list of exit codes from all processes.
346 function print_combined_run_output($processes, $stoponfail = false) {
347 $exitcodes = array();
348 $maxdotsonline = 70;
349 $remainingprintlen = $maxdotsonline;
350 $progresscount = 0;
351 while (count($exitcodes) != count($processes)) {
352 usleep(10000);
353 foreach ($processes as $name => $process) {
354 if ($process->isRunning()) {
355 $op = $process->getIncrementalOutput();
356 if (trim($op)) {
357 $update = preg_filter('#^\s*([FS\.\-]+)(?:\s+\d+)?\s*$#', '$1', $op);
358 // Exit process if anything fails.
359 if ($stoponfail && (strpos($update, 'F') !== false)) {
360 $process->stop(0);
363 $strlentoprint = strlen($update);
365 // If not enough dots printed on line then just print.
366 if ($strlentoprint < $remainingprintlen) {
367 echo $update;
368 $remainingprintlen = $remainingprintlen - $strlentoprint;
369 } else if ($strlentoprint == $remainingprintlen) {
370 $progresscount += $maxdotsonline;
371 echo $update ." " . $progresscount . PHP_EOL;
372 $remainingprintlen = $maxdotsonline;
373 } else {
374 while ($part = substr($update, 0, $remainingprintlen) > 0) {
375 $progresscount += $maxdotsonline;
376 echo $part . " " . $progresscount . PHP_EOL;
377 $update = substr($update, $remainingprintlen);
378 $remainingprintlen = $maxdotsonline;
382 } else {
383 $exitcodes[$name] = $process->getExitCode();
384 if ($stoponfail && ($exitcodes[$name] != 0)) {
385 foreach ($processes as $l => $p) {
386 $exitcodes[$l] = -1;
387 $process->stop(0);
394 echo PHP_EOL;
395 return $exitcodes;
399 * Loop though all processes and print combined o/p
401 * @param array $processes list of processes to loop though.
402 * @param bool $verbose Show verbose output for each process.
404 function print_each_process_info($processes, $verbose = false, $status = 0) {
405 foreach ($processes as $name => $process) {
406 echo "**************** [" . $name . "] ****************" . PHP_EOL;
407 if ($verbose) {
408 echo $process->getOutput();
409 echo $process->getErrorOutput();
411 } else if ($status) {
412 // Only show failed o/p.
413 $runno = str_replace(BEHAT_PARALLEL_SITE_NAME, '', $name);
414 if ((1 << ($runno - 1)) & $status) {
415 echo $process->getOutput();
416 echo $process->getErrorOutput();
417 } else {
418 echo get_status_lines_from_run_op($process);
421 } else {
422 echo get_status_lines_from_run_op($process);
424 echo PHP_EOL;
429 * Extract status information from behat o/p and return.
430 * @param Symfony\Component\Process\Process $process
431 * @return string
433 function get_status_lines_from_run_op(Symfony\Component\Process\Process $process) {
434 $statusstr = '';
435 $op = explode(PHP_EOL, $process->getOutput());
436 foreach ($op as $line) {
437 // Don't print progress .
438 if (trim($line) && (strpos($line, '.') !== 0) && (strpos($line, 'Moodle ') !== 0) &&
439 (strpos($line, 'Server OS ') !== 0) && (strpos($line, 'Started at ') !== 0) &&
440 (strpos($line, 'Browser specific fixes ') !== 0)) {
441 $statusstr .= $line . PHP_EOL;
445 return $statusstr;
449 * Return last failed status of parallel runs.
451 * @param int $fromrun starting run.
452 * @param int $torun end run.
453 * @return int status of last failure.
455 function get_last_failed_status($fromrun, $torun) {
456 $lastfailedstatus = 0;
458 for ($i = $fromrun; $i <= $torun; $i++) {
459 $filepath = behat_command::get_last_failed_test_status_file_path($i);
460 if (file_exists($filepath)) {
461 if ($lastfailedstatus = file_get_contents($filepath)) {
462 $lastfailedstatus = (int)$lastfailedstatus;
463 break;
468 return $lastfailedstatus;