hphp/test/frameworks: enable tests with output > 4096 chars
[hiphop-php.git] / hphp / test / frameworks / Runner.php
blobbcc5db20162cc57a69b12787616419c6600636f2
1 <?hh
2 require_once __DIR__."/Framework.php";
3 require_once __DIR__.'/Options.php';
4 require_once __DIR__.'/PHPUnitPatterns.php';
5 require_once __DIR__.'/Statuses.php';
6 require_once __DIR__.'/Colors.php';
7 require_once __DIR__.'/utils.php';
8 require_once __DIR__.'/ProxyInformation.php';
10 class Runner {
11 // Name could be the name of the single test file for a given framework,
12 // or the actual framework name if we are running in serial, for example
13 public string $name;
15 private string $test_information = "";
16 private string $error_information = "";
17 private string $fatal_information = "";
18 private string $diff_information = "";
19 private string $stat_information = "";
20 // Each PHPUnit process gets its' own tempdir to avoid race conditions
21 // between multiple runs
22 private ?string $temp_dir;
24 private array<resource> $pipes = [];
25 private ?resource $process = null;
26 private string $actual_test_command = "";
28 public function __construct(public Framework $framework, string $p = "") {
29 $this->name = $p === "" ? $this->framework->getName() : $p;
32 public function run(): int {
33 chdir($this->framework->getTestPath());
34 $ret_val = 0;
35 $line = "";
36 $post_test = false;
37 $pretest_data = true;
38 if ($this->initialize()) {
39 while (!(feof($this->pipes[1]))) {
40 $line = $this->getLine();
41 if ($line === null) {
42 break;
44 if ($this->isBlankLine($line)) {
45 continue;
47 // e.g. PHPUnit 3.7.28 by Sebastian Bergmann.
48 // Even if there are three lines of prologue, this will keep
49 // continuing before we call analyzeTest
50 if ($this->isPrologue($line)) {
51 $pretest_data = false;
52 continue;
54 // e.g. \nWarning: HipHop currently does not support circular
55 // reference collection
56 // e.g. No headers testing
57 // e.g. Please install runkit and enable runkit.internal_override!
58 if ($pretest_data) {
59 if ($this->checkForWarnings($line)) {
60 $this->error_information .= "PRETEST WARNING FOR ".
61 $this->name.PHP_EOL.$line.PHP_EOL.
62 $this->getTestRunStr($this->name, "RUN TEST FILE: ").PHP_EOL;
64 continue;
66 if ($this->isStop($line)) {
67 // If we have finished the tests, then we are just printing any error
68 // info and getting the final stats
69 $this->printPostTestInfo();
70 break;
72 if (!$pretest_data) {
73 // We have gotten through the prologue and any blank lines
74 // and we should be at tests now.
75 $tn_matches = array();
76 if (preg_match($this->framework->getTestNamePattern(), $line,
77 &$tn_matches) === 1) {
78 // If analyzeTest returns false, then we have most likely
79 // hit a fatal. So we bail the run.
80 if(!$this->analyzeTest($tn_matches[0])) {
81 break;
83 } else if ($this->checkForWarnings($line)) {
84 // We have a warning after the tests have supposedly started
85 // but we really don't have a test to examine.
86 // e.g.
87 // PHPUnit 3.7.28 by Sebastian Bergmann.
88 // The Xdebug extension is not loaded. No code coverage will be gen
89 // \nNotice: Use of undefined constant DRIZZLE_CON_NONE
90 $line = remove_string_from_text($line, __DIR__, "");
91 $this->error_information .= PHP_EOL.$line.PHP_EOL.
92 $this->getTestRunStr($this->name,"RUN TEST FILE: ").
93 PHP_EOL.PHP_EOL;
94 continue;
95 } else if ($this->checkForFatals($line)) {
96 // We have a fatal after the tests have supposedly started
97 // but we really don't have a test to examine.
98 // e.g.
99 // PHPUnit 3.7.28 by Sebastian Bergmann.
100 // The Xdebug extension is not loaded. No code coverage will be gen
101 // \nFatal error: Call to undefined function mysqli_report
102 $line = remove_string_from_text($line, __DIR__, "");
103 $this->fatal_information .= PHP_EOL.$this->name.
104 PHP_EOL.$line.PHP_EOL.PHP_EOL.
105 $this->getTestRunStr($this->name, "RUN TEST FILE: ").
106 PHP_EOL.PHP_EOL;
107 break;
111 $ret_val = $this->finalize();
112 $this->outputData();
113 } else {
114 error_and_exit("Could not open process to run test ".$this->name.
115 " for framework ".$this->framework->getName());
117 chdir(__DIR__);
118 return $ret_val;
121 private function analyzeTest(string $test): bool {
122 verbose("Analyzing test: ".$test.PHP_EOL);
123 // If we hit a fatal or something, we will stop the overall test running
124 // for this particular test sequence
125 $continue_testing = true;
126 // We have the test. Now just get the incoming data unitl we find some
127 // sort of status data
128 do {
129 $status = $this->getLine();
130 if ($status !== null) {
131 // No user specific information in status. Replace with empty string
132 $status = remove_string_from_text($status, __DIR__, "");
134 if ($status === null) {
135 $status = Statuses::UNKNOWN;
136 $this->fatal_information .= $test.PHP_EOL.$status.PHP_EOL.
137 $this->getTestRunStr($test, "RUN TEST FILE: ").PHP_EOL.PHP_EOL;
138 $this->stat_information = $this->name.PHP_EOL.$status.PHP_EOL;
139 $continue_testing = false;
140 break;
141 } else if ($status === Statuses::TIMEOUT) {
142 $this->fatal_information .= $test.PHP_EOL.$status.PHP_EOL.
143 $this->getTestRunStr($test, "RUN TEST FILE: ").PHP_EOL.PHP_EOL;
144 $this->stat_information = $this->name.PHP_EOL.$status.PHP_EOL;
145 $continue_testing = false;
146 break;
147 } else if ($this->checkForFatals($status)) {
148 $this->fatal_information .= $test.PHP_EOL.$status.PHP_EOL.PHP_EOL.
149 $this->getTestRunStr($test, "RUN TEST FILE: ").PHP_EOL.PHP_EOL;
150 $status = Statuses::FATAL;
151 $this->stat_information = $this->name.PHP_EOL.$status.PHP_EOL;
152 $continue_testing = false;
153 break;
154 } else if ($this->checkForWarnings($status)) {
155 // Warnings are special. We may get one or more warnings, but then
156 // a real test status will come afterwards.
157 $this->error_information .= $test.PHP_EOL.$status.PHP_EOL.PHP_EOL.
158 $this->getTestRunStr($test, "RUN TEST FILE: ").PHP_EOL.PHP_EOL;
159 continue;
161 } while (!feof($this->pipes[1]) &&
162 preg_match(PHPUnitPatterns::STATUS_CODE_PATTERN,
163 $status) === 0);
164 // Test names should have all characters before and including __DIR__
165 // removed, so that specific user info is not added
166 $test = rtrim($test, PHP_EOL);
167 $test = remove_string_from_text($test, __DIR__, null);
168 $this->test_information .= $test.PHP_EOL;
169 $this->processStatus(nullthrows($status), $test);
171 return $continue_testing;
174 private function processStatus(string $status, string $test): void {
175 // May have this if we reached the end of the file or if something
176 // wasn't printed out in optimized mode that may have been printed
177 // out in debug mode
178 if ($status === "" || $status === null) {
179 $status = Statuses::UNKNOWN;
180 } else if ($status !== Statuses::UNKNOWN && $status !== Statuses::TIMEOUT &&
181 $status !== Statuses::FATAL) {
182 // Otherwise we have, Fail, Error, Incomplete, Skip, Pass (.)
183 // First Char In case we had "F 252 / 364 (69 %)"
184 $status = $status[0];
187 $this->test_information .= $status.PHP_EOL;
189 $fbmake_name = fbmake_test_name($this->framework, $test);
190 fbmake_json(Map {'op' => 'start', 'test' => $fbmake_name});
191 fbmake_json(
192 (Map {
193 'op' => 'test_done',
194 'test' => $fbmake_name,
195 })->setAll(fbmake_result_json($this->framework, $test, $status))
197 $statuses = $this->framework->getCurrentTestStatuses();
199 if ($statuses !== null &&
200 $statuses->containsKey($test)) {
201 if ($status === $statuses[$test]) {
202 // FIX: posix_isatty(STDOUT) was always returning false, even
203 // though can print in color. Check this out later.
204 human(Colors::GREEN.Statuses::PASS.Colors::NONE);
205 } else {
206 // Red if we go from pass to something else
207 if ($statuses[$test] === '.') {
208 human(Colors::RED.Statuses::FAIL.Colors::NONE);
209 // Green if we go from something else to pass
210 } else if ($status === '.') {
211 human(Colors::GREEN.Statuses::FAIL.Colors::NONE);
212 // Blue if we go from something "faily" to something "faily"
213 // e.g., E to I or F
214 } else {
215 human(Colors::BLUE.Statuses::FAIL.Colors::NONE);
217 human(PHP_EOL."Different status in ".$this->framework->getName().
218 " for test ".$test." was ".
219 $statuses[$test].
220 " and now is ".$status.PHP_EOL);
221 $this->diff_information .= "----------------------".PHP_EOL.
222 $test.PHP_EOL.PHP_EOL.
223 $this->getTestRunStr($test, "RUN TEST FILE: ").PHP_EOL.PHP_EOL.
224 "EXPECTED: ".$statuses[$test].
225 PHP_EOL.">>>>>>>".PHP_EOL.
226 "ACTUAL: ".$status.PHP_EOL.PHP_EOL;
228 } else {
229 // This is either the first time we run the unit tests, and all pass
230 // because we are establishing a baseline. OR we have run the tests
231 // before, but we are having an issue getting to the actual tests
232 // (e.g., yii is one test suite that has behaved this way).
233 if ($statuses !== null) {
234 human(Colors::LIGHTBLUE.Statuses::FAIL.Colors::NONE);
235 human(PHP_EOL."Different status in ".$this->framework->getName().
236 " for test ".$test.PHP_EOL);
237 $this->diff_information .= "----------------------".PHP_EOL.
238 "Maybe haven't see this test before: ".$test.PHP_EOL.PHP_EOL.
239 $this->getTestRunStr($test, "RUN TEST FILE: ").PHP_EOL.PHP_EOL;
240 } else {
241 human(Colors::GRAY.Statuses::PASS.Colors::NONE);
246 private function getLine(): ?string {
247 if (feof($this->pipes[1])) {
248 return null;
250 if (!$this->checkReadStream()) {
251 return Statuses::TIMEOUT;
253 $line = "";
255 while (true) {
256 $part = stream_get_line($this->pipes[1], 4096, PHP_EOL);
257 if ($part === false || $part === null) {
258 if ($line === "") {
259 // We never read anything, so there is no line here.
260 return null;
261 } else {
262 // We've buffered some output already. Exit the loop and return
263 // that to the caller.
264 break;
266 } else {
267 $line = $line.$part;
268 if (strlen($part) < 4096) {
269 break;
273 $line = remove_color_codes($line);
274 return $line;
277 // Post test information are error/failure information and the final passing
278 // stats for the test
279 private function printPostTestInfo(): void {
280 $prev_line = null;
281 $final_stats = null;
282 $matches = array();
283 $post_stat_fatal = false;
285 // Throw out any initial blank lines
286 do {
287 $line = $this->getLine();
288 } while ($line === "" && $line !== null);
290 // Now that we have our first non-blank line, print out the test information
291 // until we have our final stats
292 while ($line !== null) {
293 // Don't print out any of the PHPUnit Patterns to the errors file.
294 // Just print out pertinent error information.
296 // There was 1 failure: <---- Don't print
297 // <blank line>
298 // 1) Assetic\Test\Asset\HttpAssetTest::testGetLastModified <---- print
299 if (preg_match(PHPUnitPatterns::TESTS_OK_SKIPPED_INC_PATTERN,
300 $line) === 1 ||
301 preg_match(PHPUnitPatterns::NUM_ERRORS_FAILURES_PATTERN,
302 $line) === 1 ||
303 preg_match(PHPUnitPatterns::FAILURES_HEADER_PATTERN,
304 $line) === 1 ||
305 preg_match(PHPUnitPatterns::NUM_SKIPS_INC_PATTERN,
306 $line) === 1) {
307 do {
308 // throw out any blank lines after these pattern
309 $line = $this->getLine();
310 } while ($line === "" && $line !== null);
311 continue;
314 // If we hit what we think is the final stats based on the pattern of the
315 // line, make sure this is the case. The final stats will generally be
316 // the last line before we hit null returned from line retrieval. The
317 // only cases where this would not be true is if, for some rare reason,
318 // stat information is part of the information provided for a
319 // given test error -- or -- we have hit a fatal at the very end of
320 // running PHPUnit. For that fatal case, we handle that a bit differently.
321 if (preg_match(PHPUnitPatterns::TESTS_OK_PATTERN, $line) === 1 ||
322 preg_match(PHPUnitPatterns::TESTS_FAILURE_PATTERN, $line) === 1 ||
323 preg_match(PHPUnitPatterns::NO_TESTS_EXECUTED_PATTERN,
324 $line) === 1) {
325 $prev_line = $line;
326 $line = $this->getLine();
327 if ($line === null) {
328 $final_stats = $prev_line;
329 break;
330 } else if ($line === "") {
331 // FIX ME: The above $line === null check is all I should need, but
332 // but getLine() is not cooperating. Not sure if getLine() problem or
333 // a PHPUnit output thing, but even when I am at the final stat line
334 // pattern, sometimes it takes me two getLine() calls to hit
335 // $line === null because I first get $line === "".
336 // So...save the current position. Read ahead. If null, we are done.
337 // Otherwise, print $prev_line, go back to where we were and the
338 // current blank line now stored in $line, will be printed down
339 // below
340 $curPos = ftell($this->pipes[1]);
341 if ($this->getLine() === null) {
342 $final_stats = $prev_line;
343 break;
344 } else {
345 $this->error_information .= $prev_line.PHP_EOL;
346 fseek($this->pipes[1], $curPos);
348 } else if ($this->checkForFatals($line) ||
349 $this->checkForWarnings($line)) {
350 // Sometimes when PHPUnit is done printing its post test info, hhvm
351 // fatals. This is not good, but it currently happens nonetheless. Here
352 // is an example:
354 // FAILURES!
355 // Tests: 3, Assertions: 9, Failures: 2. <--- EXPECTED LAST LINE (STATS)
356 // Core dumped: Segmentation fault <--- But, we can get this and below
357 // /home/joelm/bin/hhvm: line 1: 28417 Segmentation fault
358 $final_stats = $prev_line;
359 $post_stat_fatal = true;
360 break;
361 } else {
362 $this->error_information .= $prev_line.PHP_EOL;
366 $this->error_information .= $line.PHP_EOL;
367 if (preg_match($this->framework->getTestNamePattern(), $line,
368 &$matches) === 1) {
369 $print_blanks = true;
370 $this->error_information .= PHP_EOL.
371 $this->getTestRunStr($matches[0],
372 "RUN TEST FILE: ").
373 PHP_EOL.PHP_EOL;
375 $line = $this->getLine();
378 if ($post_stat_fatal) {
379 $this->fatal_information .= "POST-TEST FATAL/WARNING FOR ".
380 $this->name.PHP_EOL.PHP_EOL.
381 $this->getTestRunStr($this->name, "RUN TEST FILE: ").
382 PHP_EOL.PHP_EOL;
383 while ($line !== null) {
384 $this->fatal_information .= $line.PHP_EOL;
385 $line = $this->getLine();
387 // Add a newline to the fatal file if we had a post-test fatal for better
388 // visual
389 $this->fatal_information .= PHP_EOL;
392 // If we have no final stats, assume some sort of fatal for this test.
393 // If we have "No tests executed", assume a skip
394 // Otherwise, print the final stats.
395 $this->stat_information = $this->name.PHP_EOL;
396 if ($final_stats === null) {
397 $this->stat_information .= Statuses::FATAL.PHP_EOL;
398 } else if (preg_match(PHPUnitPatterns::NO_TESTS_EXECUTED_PATTERN,
399 $final_stats) === 1) {
400 $this->stat_information .= Statuses::SKIP.PHP_EOL;
401 } else {
402 $this->stat_information .= $final_stats.PHP_EOL;
406 private function isStop(string $line) {
407 return preg_match(PHPUnitPatterns::STOP_PARSING_PATTERN, $line) === 1;
410 private function isPrologue(string $line) {
411 return preg_match(PHPUnitPatterns::HEADER_PATTERN, $line) === 1 ||
412 preg_match(PHPUnitPatterns::CONFIG_FILE_PATTERN, $line) === 1 ||
413 preg_match(PHPUnitPatterns::XDEBUG_PATTERN, $line) === 1;
416 private function isBlankLine(string $line): bool {
417 return $line === "" || $line === PHP_EOL;
420 private function initialize(): bool {
421 $this->actual_test_command = $this->framework->getTestCommand($this->name);
422 verbose("Command: ".$this->actual_test_command."\n");
424 $descriptorspec = array(
425 0 => array("pipe", "r"),
426 1 => array("pipe", "w"),
427 2 => array("pipe", "w"),
430 $env = $_ENV;
431 // Use the proxies in case the test needs access to the outside world
432 $env = array_merge($env, nullthrows(ProxyInformation::$proxies)->toArray());
433 if ($this->framework->getEnvVars() !== null) {
434 $env = array_merge($env,
435 nullthrows($this->framework->getEnvVars())->toArray());
437 $temp_dir = sys_get_temp_dir().'/hhvm_oss_'.
438 uniqid($this->framework->getName(), true);
439 mkdir($temp_dir);
440 $this->temp_dir = $temp_dir;
441 $env['TMPDIR'] = $temp_dir;
442 $env['HHVM_NO_DEFAULT_CONFIGS'] = true;
443 $env['HHVM_CONFIG_FILE'] = $this->framework->getHHVMConfigFile();
445 $this->process = proc_open($this->actual_test_command, $descriptorspec,
446 &$this->pipes, $this->framework->getTestPath(),
447 $env);
448 return is_resource($this->process);
451 private function finalize(): int {
452 fclose($this->pipes[0]);
453 fclose($this->pipes[1]);
454 fclose($this->pipes[2]);
456 $temp_dir = $this->temp_dir;
457 $this->temp_dir = null;
458 if ($temp_dir !== null) {
459 if (!file_exists($temp_dir)) {
460 throw new Exception(
461 'Temp directory already deleted in test '.$this->name
464 remove_dir_recursive($temp_dir);
467 return proc_close($this->process) === -1 ? -1 : 0;
470 private function checkReadStream(): bool {
471 $r = array($this->pipes[1]);
472 $w = null;
473 $e = null;
474 $s = stream_select(&$r, &$w, &$e, Options::$timeout);
475 // If stream_select returns 0, then there is no more data or we have
476 // timed out. If it returns false, then something else bad happened.
477 return !($s === 0 || $s === false);
480 private function outputData(): void {
481 file_put_contents($this->framework->getOutFile(), $this->test_information,
482 FILE_APPEND | LOCK_EX);
483 file_put_contents($this->framework->getErrorsFile(),
484 $this->error_information, FILE_APPEND | LOCK_EX);
485 file_put_contents($this->framework->getDiffFile(), $this->diff_information,
486 FILE_APPEND | LOCK_EX);
487 file_put_contents($this->framework->getStatsFile(), $this->stat_information,
488 FILE_APPEND | LOCK_EX);
489 file_put_contents($this->framework->getFatalsFile(),
490 $this->fatal_information, FILE_APPEND | LOCK_EX);
494 private function checkForFatals(string $line): bool {
495 return preg_match(PHPUnitPatterns::FATAL_PATTERN, $line) === 1;
498 private function checkForWarnings(string $line): bool {
499 return preg_match(PHPUnitPatterns::WARNING_PATTERN, $line) === 1 ||
500 preg_match(PHPUnitPatterns::PHPUNIT_EXCEPTION_WITH_WARNING,
501 $line) === 1;
504 private function getTestRunStr(string $test, string $prologue = "",
505 string $epilogue = ""): string {
506 $test_run = $prologue.
507 " cd ".$this->framework->getTestPath()." && ";
508 // If the test that is coming in to this function is an individual test,
509 // as opposed to a file, then we can use the --filter option to make the
510 // run string have even more specificity.
511 if (preg_match($this->framework->getTestNamePattern(), $test)) {
512 // If we are running this framework with individual test mode
513 // (e.g., --by-test), then --filter already exists. We also don't want to
514 // add --filter to .phpt style tests (e.g. Pear).
515 if (strpos($this->actual_test_command, "--filter") === false &&
516 strpos($test, ".phpt") === false) {
517 // The string after the last space in actual_test_command is
518 // the file that is run in phpunit. Remove the file and replace
519 // with --filter <individual test>. This will also get rid of any
520 // 2>&1 that may exist as well, which we do not want.
522 // e.g.,
523 // hhvm -v Eval.Jit=true phpunit --debug 'ConverterTest.php'
524 // to
525 // hhvm -v Eval.Jit=true phpunit --debug 'ConverterTest::MyTest'
526 $t = rtrim(str_replace("2>&1", "", $this->actual_test_command));
527 $lastspace = strrpos($t, ' ');
528 $t = substr($this->actual_test_command, 0, $lastspace);
529 // For --filter, the namespaces need to be separated by \\
530 $test = str_replace("\\", "\\\\", $test);
531 $t .= " --filter '".$test."'";
532 $test_run .= $t;
533 } else if (!$this->framework->isParallel()) {
534 // If a framework is not being run in parallel (e.g., it is being run like
535 // normal phpunit for the entire framework), then the actual_test_command
536 // would not contain the individual test by default. It is being run like
537 // this, for example, from the test root directory:
539 // hhvm phpunit
541 // Pear is a current example of this behavior.
542 $test_run .= rtrim(str_replace("2>&1", "", $this->actual_test_command));
543 // Re-add __DIR__ if not there so we have a full test path to run
544 $test_run .= strpos($test, __DIR__) !== 0
545 ? " ".__DIR__."/".$test
546 : " ".$test;
547 } else {
548 $test_run .= rtrim(str_replace("2>&1", "", $this->actual_test_command));
550 } else {
551 // $test is not a XXX::YYY style test, but is instead a file that is already
552 // part of the actual_test_comand
553 $test_run .= rtrim(str_replace("2>&1", "", $this->actual_test_command));
555 return $test_run.$epilogue;