1#!/usr/bin/env php 2<?php 3/* 4 +----------------------------------------------------------------------+ 5 | Copyright (c) The PHP Group | 6 +----------------------------------------------------------------------+ 7 | This source file is subject to version 3.01 of the PHP license, | 8 | that is bundled with this package in the file LICENSE, and is | 9 | available through the world-wide-web at the following url: | 10 | https://www.php.net/license/3_01.txt | 11 | If you did not receive a copy of the PHP license and are unable to | 12 | obtain it through the world-wide-web, please send a note to | 13 | license@php.net so we can mail you a copy immediately. | 14 +----------------------------------------------------------------------+ 15 | Authors: Ilia Alshanetsky <iliaa@php.net> | 16 | Preston L. Bannister <pbannister@php.net> | 17 | Marcus Boerger <helly@php.net> | 18 | Derick Rethans <derick@php.net> | 19 | Sander Roobol <sander@php.net> | 20 | Andrea Faulds <ajf@ajf.me> | 21 | (based on version by: Stig Bakken <ssb@php.net>) | 22 | (based on the PHP 3 test framework by Rasmus Lerdorf) | 23 +----------------------------------------------------------------------+ 24 */ 25 26/* Let there be no top-level code beyond this point: 27 * Only functions and classes, thanks! 28 * 29 * Minimum required PHP version: 8.0.0 30 */ 31 32function show_usage(): void 33{ 34 echo <<<HELP 35Synopsis: 36 php run-tests.php [options] [files] [directories] 37 38Options: 39 -j<workers> Run up to <workers> simultaneous testing processes in parallel for 40 quicker testing on systems with multiple logical processors. 41 Note that this is experimental feature. 42 43 -l <file> Read the testfiles to be executed from <file>. After the test 44 has finished all failed tests are written to the same <file>. 45 If the list is empty and no further test is specified then 46 all tests are executed (same as: -r <file> -w <file>). 47 48 -r <file> Read the testfiles to be executed from <file>. 49 50 -w <file> Write a list of all failed tests to <file>. 51 52 -a <file> Same as -w but append rather then truncating <file>. 53 54 -W <file> Write a list of all tests and their result status to <file>. 55 56 -c <file> Look for php.ini in directory <file> or use <file> as ini. 57 58 -n Pass -n option to the php binary (Do not use a php.ini). 59 60 -d foo=bar Pass -d option to the php binary (Define INI entry foo 61 with value 'bar'). 62 63 -g Comma separated list of groups to show during test run 64 (possible values: PASS, FAIL, XFAIL, XLEAK, SKIP, BORK, WARN, LEAK, REDIRECT). 65 66 -m Test for memory leaks with Valgrind (equivalent to -M memcheck). 67 68 -M <tool> Test for errors with Valgrind tool. 69 70 -p <php> Specify PHP executable to run. 71 72 -P Use PHP_BINARY as PHP executable to run (default). 73 74 -q Quiet, no user interaction (same as environment NO_INTERACTION). 75 76 -s <file> Write output to <file>. 77 78 -x Sets 'SKIP_SLOW_TESTS' environment variable. 79 80 --online Prevents setting the 'SKIP_ONLINE_TESTS' environment variable. 81 82 --offline Sets 'SKIP_ONLINE_TESTS' environment variable (default). 83 84 --verbose 85 -v Verbose mode. 86 87 --help 88 -h This Help. 89 90 --temp-source <sdir> --temp-target <tdir> [--temp-urlbase <url>] 91 Write temporary files to <tdir> by replacing <sdir> from the 92 filenames to generate with <tdir>. In general you want to make 93 <sdir> the path to your source files and <tdir> some patch in 94 your web page hierarchy with <url> pointing to <tdir>. 95 96 --keep-[all|php|skip|clean] 97 Do not delete 'all' files, 'php' test file, 'skip' or 'clean' 98 file. 99 100 --set-timeout <n> 101 Set timeout for individual tests, where <n> is the number of 102 seconds. The default value is 60 seconds, or 300 seconds when 103 testing for memory leaks. 104 105 --context <n> 106 Sets the number of lines of surrounding context to print for diffs. 107 The default value is 3. 108 109 --show-[all|php|skip|clean|exp|diff|out|mem] 110 Show 'all' files, 'php' test file, 'skip' or 'clean' file. You 111 can also use this to show the output 'out', the expected result 112 'exp', the difference between them 'diff' or the valgrind log 113 'mem'. The result types get written independent of the log format, 114 however 'diff' only exists when a test fails. 115 116 --show-slow <n> 117 Show all tests that took longer than <n> milliseconds to run. 118 119 --no-clean Do not execute clean section if any. 120 121 --color 122 --no-color Do/Don't colorize the result type in the test result. 123 124 --progress 125 --no-progress Do/Don't show the current progress. 126 127 --repeat [n] 128 Run the tests multiple times in the same process and check the 129 output of the last execution (CLI SAPI only). 130 131 --bless Bless failed tests using scripts/dev/bless_tests.php. 132 133HELP; 134} 135 136/** 137 * One function to rule them all, one function to find them, one function to 138 * bring them all and in the darkness bind them. 139 * This is the entry point and exit point überfunction. It contains all the 140 * code that was previously found at the top level. It could and should be 141 * refactored to be smaller and more manageable. 142 */ 143function main(): void 144{ 145 /* This list was derived in a naïve mechanical fashion. If a member 146 * looks like it doesn't belong, it probably doesn't; cull at will. 147 */ 148 global $DETAILED, $PHP_FAILED_TESTS, $SHOW_ONLY_GROUPS, $argc, $argv, $cfg, 149 $end_time, $environment, 150 $exts_skipped, $exts_tested, $exts_to_test, $failed_tests_file, 151 $ignored_by_ext, $ini_overwrites, $colorize, 152 $log_format, $no_clean, $no_file_cache, 153 $pass_options, $php, $php_cgi, $preload, 154 $result_tests_file, $slow_min_ms, $start_time, 155 $temp_source, $temp_target, $test_cnt, 156 $test_files, $test_idx, $test_results, $testfile, 157 $valgrind, $sum_results, $shuffle, $file_cache, $num_repeats, 158 $show_progress; 159 // Parallel testing 160 global $workers, $workerID; 161 global $context_line_count; 162 163 // Temporary for the duration of refactoring 164 /** @var JUnit $junit */ 165 global $junit; 166 167 define('IS_WINDOWS', substr(PHP_OS, 0, 3) == "WIN"); 168 169 $workerID = 0; 170 if (getenv("TEST_PHP_WORKER")) { 171 $workerID = intval(getenv("TEST_PHP_WORKER")); 172 run_worker(); 173 return; 174 } 175 176 define('INIT_DIR', getcwd()); 177 178 // Change into the PHP source directory. 179 if (getenv('TEST_PHP_SRCDIR')) { 180 @chdir(getenv('TEST_PHP_SRCDIR')); 181 } 182 183 define('TEST_PHP_SRCDIR', getcwd()); 184 185 check_proc_open_function_exists(); 186 187 // If timezone is not set, use UTC. 188 if (ini_get('date.timezone') == '') { 189 date_default_timezone_set('UTC'); 190 } 191 192 // Delete some security related environment variables 193 putenv('SSH_CLIENT=deleted'); 194 putenv('SSH_AUTH_SOCK=deleted'); 195 putenv('SSH_TTY=deleted'); 196 putenv('SSH_CONNECTION=deleted'); 197 198 set_time_limit(0); 199 200 ini_set('pcre.backtrack_limit', PHP_INT_MAX); 201 202 init_output_buffers(); 203 204 error_reporting(E_ALL); 205 206 $environment = $_ENV ?? []; 207 208 // Some configurations like php.ini-development set variables_order="GPCS" 209 // not "EGPCS", in which case $_ENV is NOT populated. Detect if the $_ENV 210 // was empty and handle it by explicitly populating through getenv(). 211 if (empty($environment)) { 212 $environment = getenv(); 213 } 214 215 if (empty($environment['TEMP'])) { 216 $environment['TEMP'] = sys_get_temp_dir(); 217 218 if (empty($environment['TEMP'])) { 219 // For example, OpCache on Windows will fail in this case because 220 // child processes (for tests) will not get a TEMP variable, so 221 // GetTempPath() will fallback to c:\windows, while GetTempPath() 222 // will return %TEMP% for parent (likely a different path). The 223 // parent will initialize the OpCache in that path, and child will 224 // fail to reattach to the OpCache because it will be using the 225 // wrong path. 226 die("TEMP environment is NOT set"); 227 } 228 229 if (count($environment) == 1) { 230 // Not having other environment variables, only having TEMP, is 231 // probably ok, but strange and may make a difference in the 232 // test pass rate, so warn the user. 233 echo "WARNING: Only 1 environment variable will be available to tests(TEMP environment variable)" , PHP_EOL; 234 } 235 } 236 237 if (IS_WINDOWS && empty($environment["SystemRoot"])) { 238 $environment["SystemRoot"] = getenv("SystemRoot"); 239 } 240 241 if (getenv('TEST_PHP_LOG_FORMAT')) { 242 $log_format = strtoupper(getenv('TEST_PHP_LOG_FORMAT')); 243 } else { 244 $log_format = 'LEODS'; 245 } 246 247 // Check whether a detailed log is wanted. 248 if (getenv('TEST_PHP_DETAILED')) { 249 $DETAILED = getenv('TEST_PHP_DETAILED'); 250 } else { 251 $DETAILED = 0; 252 } 253 254 $junit = new JUnit($environment, $workerID); 255 256 if (getenv('SHOW_ONLY_GROUPS')) { 257 $SHOW_ONLY_GROUPS = explode(",", getenv('SHOW_ONLY_GROUPS')); 258 } else { 259 $SHOW_ONLY_GROUPS = []; 260 } 261 262 // Check whether user test dirs are requested. 263 $user_tests = []; 264 if (getenv('TEST_PHP_USER')) { 265 $user_tests = explode(',', getenv('TEST_PHP_USER')); 266 } 267 268 $exts_to_test = []; 269 $ini_overwrites = [ 270 'output_handler=', 271 'open_basedir=', 272 'disable_functions=', 273 'output_buffering=Off', 274 'error_reporting=' . E_ALL, 275 'display_errors=1', 276 'display_startup_errors=1', 277 'log_errors=0', 278 'html_errors=0', 279 'track_errors=0', 280 'report_memleaks=1', 281 'report_zend_debug=0', 282 'docref_root=', 283 'docref_ext=.html', 284 'error_prepend_string=', 285 'error_append_string=', 286 'auto_prepend_file=', 287 'auto_append_file=', 288 'ignore_repeated_errors=0', 289 'precision=14', 290 'serialize_precision=-1', 291 'memory_limit=128M', 292 'opcache.fast_shutdown=0', 293 'opcache.file_update_protection=0', 294 'opcache.revalidate_freq=0', 295 'opcache.jit_hot_loop=1', 296 'opcache.jit_hot_func=1', 297 'opcache.jit_hot_return=1', 298 'opcache.jit_hot_side_exit=1', 299 'opcache.jit_max_root_traces=100000', 300 'opcache.jit_max_side_traces=100000', 301 'opcache.jit_max_exit_counters=100000', 302 'opcache.protect_memory=1', 303 'zend.assertions=1', 304 'zend.exception_ignore_args=0', 305 'zend.exception_string_param_max_len=15', 306 'short_open_tag=0', 307 ]; 308 309 $no_file_cache = '-d opcache.file_cache= -d opcache.file_cache_only=0'; 310 311 // Determine the tests to be run. 312 313 $test_files = []; 314 $redir_tests = []; 315 $test_results = []; 316 $PHP_FAILED_TESTS = [ 317 'BORKED' => [], 318 'FAILED' => [], 319 'WARNED' => [], 320 'LEAKED' => [], 321 'XFAILED' => [], 322 'XLEAKED' => [], 323 'SLOW' => [] 324 ]; 325 326 // If parameters given assume they represent selected tests to run. 327 $result_tests_file = false; 328 $failed_tests_file = false; 329 $pass_option_n = false; 330 $pass_options = ''; 331 332 $output_file = INIT_DIR . '/php_test_results_' . date('Ymd_Hi') . '.txt'; 333 334 $just_save_results = false; 335 $valgrind = null; 336 $temp_source = null; 337 $temp_target = null; 338 $conf_passed = null; 339 $no_clean = false; 340 $colorize = true; 341 if (function_exists('sapi_windows_vt100_support') && !sapi_windows_vt100_support(STDOUT, true)) { 342 $colorize = false; 343 } 344 if (array_key_exists('NO_COLOR', $environment)) { 345 $colorize = false; 346 } 347 $selected_tests = false; 348 $slow_min_ms = INF; 349 $preload = false; 350 $file_cache = null; 351 $shuffle = false; 352 $bless = false; 353 $workers = null; 354 $context_line_count = 3; 355 $num_repeats = 1; 356 $show_progress = true; 357 $ignored_by_ext = []; 358 $online = null; 359 360 $cfgtypes = ['show', 'keep']; 361 $cfgfiles = ['skip', 'php', 'clean', 'out', 'diff', 'exp', 'mem']; 362 $cfg = []; 363 364 foreach ($cfgtypes as $type) { 365 $cfg[$type] = []; 366 367 foreach ($cfgfiles as $file) { 368 $cfg[$type][$file] = false; 369 } 370 } 371 372 if (!isset($argc, $argv) || !$argc) { 373 $argv = [__FILE__]; 374 $argc = 1; 375 } 376 377 if (getenv('TEST_PHP_ARGS')) { 378 $argv = array_merge($argv, explode(' ', getenv('TEST_PHP_ARGS'))); 379 $argc = count($argv); 380 } 381 382 for ($i = 1; $i < $argc; $i++) { 383 $is_switch = false; 384 $switch = substr($argv[$i], 1, 1); 385 $repeat = substr($argv[$i], 0, 1) == '-'; 386 387 while ($repeat) { 388 if (!$is_switch) { 389 $switch = substr($argv[$i], 1, 1); 390 } 391 392 $is_switch = true; 393 394 foreach ($cfgtypes as $type) { 395 if (strpos($switch, '--' . $type) === 0) { 396 foreach ($cfgfiles as $file) { 397 if ($switch == '--' . $type . '-' . $file) { 398 $cfg[$type][$file] = true; 399 $is_switch = false; 400 break; 401 } 402 } 403 } 404 } 405 406 if (!$is_switch) { 407 $is_switch = true; 408 break; 409 } 410 411 $repeat = false; 412 413 switch ($switch) { 414 case 'j': 415 $workers = substr($argv[$i], 2); 416 if ($workers == 0 || !preg_match('/^\d+$/', $workers)) { 417 error("'$workers' is not a valid number of workers, try e.g. -j16 for 16 workers"); 418 } 419 $workers = intval($workers, 10); 420 // Don't use parallel testing infrastructure if there is only one worker. 421 if ($workers === 1) { 422 $workers = null; 423 } 424 break; 425 case 'r': 426 case 'l': 427 $test_list = file($argv[++$i]); 428 if ($test_list) { 429 foreach ($test_list as $test) { 430 $matches = []; 431 if (preg_match('/^#.*\[(.*)\]\:\s+(.*)$/', $test, $matches)) { 432 $redir_tests[] = [$matches[1], $matches[2]]; 433 } elseif (strlen($test)) { 434 $test_files[] = trim($test); 435 } 436 } 437 } 438 if ($switch != 'l') { 439 break; 440 } 441 $i--; 442 // no break 443 case 'w': 444 $failed_tests_file = fopen($argv[++$i], 'w+t'); 445 break; 446 case 'a': 447 $failed_tests_file = fopen($argv[++$i], 'a+t'); 448 break; 449 case 'W': 450 $result_tests_file = fopen($argv[++$i], 'w+t'); 451 break; 452 case 'c': 453 $conf_passed = $argv[++$i]; 454 break; 455 case 'd': 456 $ini_overwrites[] = $argv[++$i]; 457 break; 458 case 'g': 459 $SHOW_ONLY_GROUPS = explode(",", $argv[++$i]); 460 break; 461 case '--keep-all': 462 foreach ($cfgfiles as $file) { 463 $cfg['keep'][$file] = true; 464 } 465 break; 466 case 'm': 467 $valgrind = new RuntestsValgrind($environment); 468 break; 469 case 'M': 470 $valgrind = new RuntestsValgrind($environment, $argv[++$i]); 471 break; 472 case 'n': 473 if (!$pass_option_n) { 474 $pass_options .= ' -n'; 475 } 476 $pass_option_n = true; 477 break; 478 case 'e': 479 $pass_options .= ' -e'; 480 break; 481 case '--preload': 482 $preload = true; 483 $environment['SKIP_PRELOAD'] = 1; 484 break; 485 case '--file-cache-prime': 486 $file_cache = 'prime'; 487 break; 488 case '--file-cache-use': 489 $file_cache = 'use'; 490 break; 491 case '--no-clean': 492 $no_clean = true; 493 break; 494 case '--color': 495 $colorize = true; 496 break; 497 case '--no-color': 498 $colorize = false; 499 break; 500 case 'p': 501 $php = $argv[++$i]; 502 putenv("TEST_PHP_EXECUTABLE=$php"); 503 $environment['TEST_PHP_EXECUTABLE'] = $php; 504 break; 505 case 'P': 506 $php = PHP_BINARY; 507 putenv("TEST_PHP_EXECUTABLE=$php"); 508 $environment['TEST_PHP_EXECUTABLE'] = $php; 509 break; 510 case 'q': 511 putenv('NO_INTERACTION=1'); 512 $environment['NO_INTERACTION'] = 1; 513 break; 514 case 's': 515 $output_file = $argv[++$i]; 516 $just_save_results = true; 517 break; 518 case '--set-timeout': 519 $timeout = $argv[++$i] ?? ''; 520 if (!preg_match('/^\d+$/', $timeout)) { 521 error("'$timeout' is not a valid number of seconds, try e.g. --set-timeout 60 for 1 minute"); 522 } 523 $environment['TEST_TIMEOUT'] = intval($timeout, 10); 524 break; 525 case '--context': 526 $context_line_count = $argv[++$i] ?? ''; 527 if (!preg_match('/^\d+$/', $context_line_count)) { 528 error("'$context_line_count' is not a valid number of lines of context, try e.g. --context 3 for 3 lines"); 529 } 530 $context_line_count = intval($context_line_count, 10); 531 break; 532 case '--show-all': 533 foreach ($cfgfiles as $file) { 534 $cfg['show'][$file] = true; 535 } 536 break; 537 case '--show-slow': 538 $slow_min_ms = $argv[++$i] ?? ''; 539 if (!preg_match('/^\d+$/', $slow_min_ms)) { 540 error("'$slow_min_ms' is not a valid number of milliseconds, try e.g. --show-slow 1000 for 1 second"); 541 } 542 $slow_min_ms = intval($slow_min_ms, 10); 543 break; 544 case '--temp-source': 545 $temp_source = $argv[++$i]; 546 break; 547 case '--temp-target': 548 $temp_target = $argv[++$i]; 549 break; 550 case 'v': 551 case '--verbose': 552 $DETAILED = true; 553 break; 554 case 'x': 555 $environment['SKIP_SLOW_TESTS'] = 1; 556 break; 557 case '--online': 558 $online = true; 559 break; 560 case '--offline': 561 $online = false; 562 break; 563 case '--shuffle': 564 $shuffle = true; 565 break; 566 case '--asan': 567 case '--msan': 568 $environment['USE_ZEND_ALLOC'] = 0; 569 $environment['USE_TRACKED_ALLOC'] = 1; 570 $environment['SKIP_ASAN'] = 1; 571 $environment['SKIP_PERF_SENSITIVE'] = 1; 572 if ($switch === '--msan') { 573 $environment['SKIP_MSAN'] = 1; 574 $environment['MSAN_OPTIONS'] = 'intercept_tls_get_addr=0'; 575 } 576 577 $lsanSuppressions = __DIR__ . '/.github/lsan-suppressions.txt'; 578 if (file_exists($lsanSuppressions)) { 579 $environment['LSAN_OPTIONS'] = 'suppressions=' . $lsanSuppressions 580 . ':print_suppressions=0'; 581 } 582 break; 583 case '--repeat': 584 $num_repeats = (int) $argv[++$i]; 585 $environment['SKIP_REPEAT'] = 1; 586 break; 587 case '--bless': 588 $bless = true; 589 break; 590 case '-': 591 // repeat check with full switch 592 $switch = $argv[$i]; 593 if ($switch != '-') { 594 $repeat = true; 595 } 596 break; 597 case '--progress': 598 $show_progress = true; 599 break; 600 case '--no-progress': 601 $show_progress = false; 602 break; 603 case '--version': 604 echo '$Id: 4654ead998f9aabe1bb9a10badec4b09caeeef2c $' . "\n"; 605 exit(1); 606 607 default: 608 echo "Illegal switch '$switch' specified!\n"; 609 // no break 610 case 'h': 611 case '-help': 612 case '--help': 613 show_usage(); 614 exit(1); 615 } 616 } 617 618 if (!$is_switch) { 619 $selected_tests = true; 620 $testfile = realpath($argv[$i]); 621 622 if (!$testfile && strpos($argv[$i], '*') !== false && function_exists('glob')) { 623 if (substr($argv[$i], -5) == '.phpt') { 624 $pattern_match = glob($argv[$i]); 625 } elseif (preg_match("/\*$/", $argv[$i])) { 626 $pattern_match = glob($argv[$i] . '.phpt'); 627 } else { 628 die('Cannot find test file "' . $argv[$i] . '".' . PHP_EOL); 629 } 630 631 if (is_array($pattern_match)) { 632 $test_files = array_merge($test_files, $pattern_match); 633 } 634 } elseif (is_dir($testfile)) { 635 find_files($testfile); 636 } elseif (substr($testfile, -5) == '.phpt') { 637 $test_files[] = $testfile; 638 } else { 639 die('Cannot find test file "' . $argv[$i] . '".' . PHP_EOL); 640 } 641 } 642 } 643 644 if ($online === null && !isset($environment['SKIP_ONLINE_TESTS'])) { 645 $online = false; 646 } 647 if ($online !== null) { 648 $environment['SKIP_ONLINE_TESTS'] = $online ? '0' : '1'; 649 } 650 651 if ($selected_tests && count($test_files) === 0) { 652 echo "No tests found.\n"; 653 return; 654 } 655 656 if (!$php) { 657 $php = getenv('TEST_PHP_EXECUTABLE') ?: PHP_BINARY; 658 } 659 660 $php_cgi = getenv('TEST_PHP_CGI_EXECUTABLE') ?: get_binary($php, 'php-cgi', 'sapi/cgi/php-cgi'); 661 $phpdbg = getenv('TEST_PHPDBG_EXECUTABLE') ?: get_binary($php, 'phpdbg', 'sapi/phpdbg/phpdbg'); 662 663 putenv("TEST_PHP_EXECUTABLE=$php"); 664 $environment['TEST_PHP_EXECUTABLE'] = $php; 665 putenv("TEST_PHP_EXECUTABLE_ESCAPED=" . escapeshellarg($php)); 666 $environment['TEST_PHP_EXECUTABLE_ESCAPED'] = escapeshellarg($php); 667 putenv("TEST_PHP_CGI_EXECUTABLE=$php_cgi"); 668 $environment['TEST_PHP_CGI_EXECUTABLE'] = $php_cgi; 669 putenv("TEST_PHP_CGI_EXECUTABLE_ESCAPED=" . escapeshellarg($php_cgi ?? '')); 670 $environment['TEST_PHP_CGI_EXECUTABLE_ESCAPED'] = escapeshellarg($php_cgi ?? ''); 671 putenv("TEST_PHPDBG_EXECUTABLE=$phpdbg"); 672 $environment['TEST_PHPDBG_EXECUTABLE'] = $phpdbg; 673 putenv("TEST_PHPDBG_EXECUTABLE_ESCAPED=" . escapeshellarg($phpdbg ?? '')); 674 $environment['TEST_PHPDBG_EXECUTABLE_ESCAPED'] = escapeshellarg($phpdbg ?? ''); 675 676 if ($conf_passed !== null) { 677 if (IS_WINDOWS) { 678 $pass_options .= " -c " . escapeshellarg($conf_passed); 679 } else { 680 $pass_options .= " -c '" . realpath($conf_passed) . "'"; 681 } 682 } 683 684 $test_files = array_unique($test_files); 685 $test_files = array_merge($test_files, $redir_tests); 686 687 // Run selected tests. 688 $test_cnt = count($test_files); 689 690 verify_config($php); 691 write_information($user_tests, $phpdbg); 692 693 if ($test_cnt) { 694 putenv('NO_INTERACTION=1'); 695 usort($test_files, "test_sort"); 696 $start_timestamp = time(); 697 $start_time = hrtime(true); 698 699 echo "Running selected tests.\n"; 700 701 $test_idx = 0; 702 run_all_tests($test_files, $environment); 703 $end_time = hrtime(true); 704 705 if ($failed_tests_file) { 706 fclose($failed_tests_file); 707 } 708 709 if ($result_tests_file) { 710 fclose($result_tests_file); 711 } 712 713 if (0 == count($test_results)) { 714 echo "No tests were run.\n"; 715 return; 716 } 717 718 compute_summary(); 719 echo "====================================================================="; 720 echo get_summary(false); 721 722 if ($output_file != '' && $just_save_results) { 723 save_results($output_file, /* prompt_to_save_results: */ false); 724 } 725 } else { 726 // Compile a list of all test files (*.phpt). 727 $test_files = []; 728 $exts_tested = $exts_to_test; 729 $exts_skipped = []; 730 sort($exts_to_test); 731 732 foreach (['Zend', 'tests', 'ext', 'sapi'] as $dir) { 733 if (is_dir($dir)) { 734 find_files(TEST_PHP_SRCDIR . "/{$dir}", $dir == 'ext'); 735 } 736 } 737 738 foreach ($user_tests as $dir) { 739 find_files($dir, $dir == 'ext'); 740 } 741 742 $test_files = array_unique($test_files); 743 usort($test_files, "test_sort"); 744 745 $start_timestamp = time(); 746 $start_time = hrtime(true); 747 show_start($start_timestamp); 748 749 $test_cnt = count($test_files); 750 $test_idx = 0; 751 run_all_tests($test_files, $environment); 752 $end_time = hrtime(true); 753 754 if ($failed_tests_file) { 755 fclose($failed_tests_file); 756 } 757 758 if ($result_tests_file) { 759 fclose($result_tests_file); 760 } 761 762 // Summarize results 763 764 if (0 == count($test_results)) { 765 echo "No tests were run.\n"; 766 return; 767 } 768 769 compute_summary(); 770 771 show_end($start_timestamp, $start_time, $end_time); 772 show_summary(); 773 774 save_results($output_file, /* prompt_to_save_results: */ true); 775 } 776 777 $junit->saveXML(); 778 if ($bless) { 779 bless_failed_tests($PHP_FAILED_TESTS['FAILED']); 780 } 781 if (getenv('REPORT_EXIT_STATUS') !== '0' && getenv('REPORT_EXIT_STATUS') !== 'no' && 782 ($sum_results['FAILED'] || $sum_results['BORKED'] || $sum_results['LEAKED'])) { 783 exit(1); 784 } 785} 786 787if (!function_exists("hrtime")) { 788 /** 789 * @return array|float|int 790 */ 791 function hrtime(bool $as_num = false) 792 { 793 $t = microtime(true); 794 795 if ($as_num) { 796 return $t * 1000000000; 797 } 798 799 $s = floor($t); 800 return [0 => $s, 1 => ($t - $s) * 1000000000]; 801 } 802} 803 804function verify_config(string $php): void 805{ 806 if (empty($php) || !file_exists($php)) { 807 error('environment variable TEST_PHP_EXECUTABLE must be set to specify PHP executable!'); 808 } 809 810 if (!is_executable($php)) { 811 error("invalid PHP executable specified by TEST_PHP_EXECUTABLE = $php"); 812 } 813} 814 815/** 816 * @param string[] $user_tests 817 */ 818function write_information(array $user_tests, $phpdbg): void 819{ 820 global $php, $php_cgi, $php_info, $ini_overwrites, $pass_options, $exts_to_test, $valgrind, $no_file_cache; 821 $php_escaped = escapeshellarg($php); 822 823 // Get info from php 824 $info_file = __DIR__ . '/run-test-info.php'; 825 @unlink($info_file); 826 $php_info = '<?php echo " 827PHP_SAPI : " , PHP_SAPI , " 828PHP_VERSION : " , phpversion() , " 829ZEND_VERSION: " , zend_version() , " 830PHP_OS : " , PHP_OS , " - " , php_uname() , " 831INI actual : " , realpath(get_cfg_var("cfg_file_path")) , " 832More .INIs : " , (function_exists(\'php_ini_scanned_files\') ? str_replace("\n","", php_ini_scanned_files()) : "** not determined **"); ?>'; 833 save_text($info_file, $php_info); 834 $info_params = []; 835 settings2array($ini_overwrites, $info_params); 836 $info_params = settings2params($info_params); 837 $php_info = shell_exec("$php_escaped $pass_options $info_params $no_file_cache \"$info_file\""); 838 define('TESTED_PHP_VERSION', shell_exec("$php_escaped -n -r \"echo PHP_VERSION;\"")); 839 840 if ($php_cgi && $php != $php_cgi) { 841 $php_cgi_escaped = escapeshellarg($php_cgi); 842 $php_info_cgi = shell_exec("$php_cgi_escaped $pass_options $info_params $no_file_cache -q \"$info_file\""); 843 $php_info_sep = "\n---------------------------------------------------------------------"; 844 $php_cgi_info = "$php_info_sep\nPHP : $php_cgi $php_info_cgi$php_info_sep"; 845 } else { 846 $php_cgi_info = ''; 847 } 848 849 if ($phpdbg) { 850 $phpdbg_escaped = escapeshellarg($phpdbg); 851 $phpdbg_info = shell_exec("$phpdbg_escaped $pass_options $info_params $no_file_cache -qrr \"$info_file\""); 852 $php_info_sep = "\n---------------------------------------------------------------------"; 853 $phpdbg_info = "$php_info_sep\nPHP : $phpdbg $phpdbg_info$php_info_sep"; 854 } else { 855 $phpdbg_info = ''; 856 } 857 858 if (function_exists('opcache_invalidate')) { 859 opcache_invalidate($info_file, true); 860 } 861 @unlink($info_file); 862 863 // load list of enabled and loadable extensions 864 save_text($info_file, <<<'PHP' 865 <?php 866 $exts = get_loaded_extensions(); 867 $ext_dir = ini_get('extension_dir'); 868 foreach (scandir($ext_dir) as $file) { 869 if (preg_match('/^(?:php_)?([_a-zA-Z0-9]+)\.(?:so|dll)$/', $file, $matches)) { 870 if (!extension_loaded($matches[1]) && @dl($matches[1])) { 871 $exts[] = $matches[1]; 872 } 873 } 874 } 875 echo implode(',', $exts); 876 PHP); 877 $extensionsNames = explode(',', shell_exec("$php_escaped $pass_options $info_params $no_file_cache \"$info_file\"")); 878 $exts_to_test = array_unique(remap_loaded_extensions_names($extensionsNames)); 879 // check for extensions that need special handling and regenerate 880 $info_params_ex = [ 881 'session' => ['session.auto_start=0'], 882 'tidy' => ['tidy.clean_output=0'], 883 'zlib' => ['zlib.output_compression=Off'], 884 'xdebug' => ['xdebug.mode=off'], 885 ]; 886 887 foreach ($info_params_ex as $ext => $ini_overwrites_ex) { 888 if (in_array($ext, $exts_to_test)) { 889 $ini_overwrites = array_merge($ini_overwrites, $ini_overwrites_ex); 890 } 891 } 892 893 if (function_exists('opcache_invalidate')) { 894 opcache_invalidate($info_file, true); 895 } 896 @unlink($info_file); 897 898 // Write test context information. 899 echo " 900===================================================================== 901PHP : $php $php_info $php_cgi_info $phpdbg_info 902CWD : " . TEST_PHP_SRCDIR . " 903Extra dirs : "; 904 foreach ($user_tests as $test_dir) { 905 echo "{$test_dir}\n "; 906 } 907 echo " 908VALGRIND : " . ($valgrind ? $valgrind->getHeader() : 'Not used') . " 909===================================================================== 910"; 911} 912 913function save_results(string $output_file, bool $prompt_to_save_results): void 914{ 915 global $sum_results, $failed_test_summary, $PHP_FAILED_TESTS, $php; 916 917 if (getenv('NO_INTERACTION')) { 918 return; 919 } 920 921 if ($prompt_to_save_results) { 922 /* We got failed Tests, offer the user to save a QA report */ 923 $fp = fopen("php://stdin", "r+"); 924 if ($sum_results['FAILED'] || $sum_results['BORKED'] || $sum_results['WARNED'] || $sum_results['LEAKED']) { 925 echo "\nYou may have found a problem in PHP."; 926 } 927 echo "\nThis report can be saved and used to open an issue on the bug tracker at\n"; 928 echo "https://github.com/php/php-src/issues\n"; 929 echo "This gives us a better understanding of PHP's behavior.\n"; 930 echo "Do you want to save this report in a file? [Yn]: "; 931 flush(); 932 933 $user_input = fgets($fp, 10); 934 fclose($fp); 935 if (!(strlen(trim($user_input)) == 0 || strtolower($user_input[0]) == 'y')) { 936 return; 937 } 938 } 939 /** 940 * Collect information about the host system for our report 941 * Fetch phpinfo() output so that we can see the PHP environment 942 * Make an archive of all the failed tests 943 */ 944 $failed_tests_data = ''; 945 $sep = "\n" . str_repeat('=', 80) . "\n"; 946 $failed_tests_data .= $failed_test_summary . "\n"; 947 $failed_tests_data .= get_summary(true) . "\n"; 948 949 if ($sum_results['FAILED']) { 950 foreach ($PHP_FAILED_TESTS['FAILED'] as $test_info) { 951 $failed_tests_data .= $sep . $test_info['name'] . $test_info['info']; 952 $failed_tests_data .= $sep . file_get_contents(realpath($test_info['output'])); 953 $failed_tests_data .= $sep . file_get_contents(realpath($test_info['diff'])); 954 $failed_tests_data .= $sep . "\n\n"; 955 } 956 } 957 958 $failed_tests_data .= "\n" . $sep . 'BUILD ENVIRONMENT' . $sep; 959 $failed_tests_data .= "OS:\n" . PHP_OS . " - " . php_uname() . "\n\n"; 960 $ldd = $autoconf = $sys_libtool = $libtool = $compiler = 'N/A'; 961 962 if (!IS_WINDOWS) { 963 /* If PHP_AUTOCONF is set, use it; otherwise, use 'autoconf'. */ 964 if (getenv('PHP_AUTOCONF')) { 965 $autoconf = shell_exec(getenv('PHP_AUTOCONF') . ' --version'); 966 } else { 967 $autoconf = shell_exec('autoconf --version'); 968 } 969 970 /* Always use the generated libtool - Mac OSX uses 'glibtool' */ 971 $libtool = shell_exec(INIT_DIR . '/libtool --version'); 972 973 /* Use shtool to find out if there is glibtool present (MacOSX) */ 974 $sys_libtool_path = shell_exec(__DIR__ . '/build/shtool path glibtool libtool'); 975 976 if ($sys_libtool_path) { 977 $sys_libtool = shell_exec(str_replace("\n", "", $sys_libtool_path) . ' --version'); 978 } 979 980 /* Try the most common flags for 'version' */ 981 $flags = ['-v', '-V', '--version']; 982 $cc_status = 0; 983 984 foreach ($flags as $flag) { 985 system(getenv('CC') . " $flag >/dev/null 2>&1", $cc_status); 986 if ($cc_status == 0) { 987 $compiler = shell_exec(getenv('CC') . " $flag 2>&1"); 988 break; 989 } 990 } 991 992 $ldd = shell_exec("ldd $php 2>/dev/null"); 993 } 994 995 $failed_tests_data .= "Autoconf:\n$autoconf\n"; 996 $failed_tests_data .= "Bundled Libtool:\n$libtool\n"; 997 $failed_tests_data .= "System Libtool:\n$sys_libtool\n"; 998 $failed_tests_data .= "Compiler:\n$compiler\n"; 999 $failed_tests_data .= "Bison:\n" . shell_exec('bison --version 2>/dev/null') . "\n"; 1000 $failed_tests_data .= "Libraries:\n$ldd\n"; 1001 $failed_tests_data .= "\n"; 1002 $failed_tests_data .= $sep . "PHPINFO" . $sep; 1003 $failed_tests_data .= shell_exec($php . ' -ddisplay_errors=stderr -dhtml_errors=0 -i 2> /dev/null'); 1004 1005 file_put_contents($output_file, $failed_tests_data); 1006 echo "Report saved to: ", $output_file, "\n"; 1007} 1008 1009function get_binary(string $php, string $sapi, string $sapi_path): ?string 1010{ 1011 $dir = dirname($php); 1012 if (IS_WINDOWS && file_exists("$dir/$sapi.exe")) { 1013 return realpath("$dir/$sapi.exe"); 1014 } 1015 // Sources tree 1016 if (file_exists("$dir/../../$sapi_path")) { 1017 return realpath("$dir/../../$sapi_path"); 1018 } 1019 // Installation tree, preserve command prefix/suffix 1020 $inst = str_replace('php', $sapi, basename($php)); 1021 if (file_exists("$dir/$inst")) { 1022 return realpath("$dir/$inst"); 1023 } 1024 return null; 1025} 1026 1027function find_files(string $dir, bool $is_ext_dir = false, bool $ignore = false): void 1028{ 1029 global $test_files, $exts_to_test, $ignored_by_ext, $exts_skipped; 1030 1031 $o = opendir($dir) or error("cannot open directory: $dir"); 1032 1033 while (($name = readdir($o)) !== false) { 1034 if (is_dir("{$dir}/{$name}") && !in_array($name, ['.', '..', '.svn'])) { 1035 $skip_ext = ($is_ext_dir && !in_array($name, $exts_to_test)); 1036 if ($skip_ext) { 1037 $exts_skipped[] = $name; 1038 } 1039 find_files("{$dir}/{$name}", false, $ignore || $skip_ext); 1040 } 1041 1042 // Cleanup any left-over tmp files from last run. 1043 if (substr($name, -4) == '.tmp') { 1044 @unlink("$dir/$name"); 1045 continue; 1046 } 1047 1048 // Otherwise we're only interested in *.phpt files. 1049 // (but not those starting with a dot, which are hidden on 1050 // many platforms) 1051 if (substr($name, -5) == '.phpt' && substr($name, 0, 1) !== '.') { 1052 $testfile = realpath("{$dir}/{$name}"); 1053 if ($ignore) { 1054 $ignored_by_ext[] = $testfile; 1055 } else { 1056 $test_files[] = $testfile; 1057 } 1058 } 1059 } 1060 1061 closedir($o); 1062} 1063 1064/** 1065 * @param array|string $name 1066 */ 1067function test_name($name): string 1068{ 1069 if (is_array($name)) { 1070 return $name[0] . ':' . $name[1]; 1071 } 1072 1073 return $name; 1074} 1075/** 1076 * @param array|string $a 1077 * @param array|string $b 1078 */ 1079function test_sort($a, $b): int 1080{ 1081 $a = test_name($a); 1082 $b = test_name($b); 1083 1084 $ta = strpos($a, TEST_PHP_SRCDIR . "/tests") === 0 ? 1 + (strpos($a, 1085 TEST_PHP_SRCDIR . "/tests/run-test") === 0 ? 1 : 0) : 0; 1086 $tb = strpos($b, TEST_PHP_SRCDIR . "/tests") === 0 ? 1 + (strpos($b, 1087 TEST_PHP_SRCDIR . "/tests/run-test") === 0 ? 1 : 0) : 0; 1088 1089 if ($ta == $tb) { 1090 return strcmp($a, $b); 1091 } 1092 1093 return $tb - $ta; 1094} 1095 1096// 1097// Write the given text to a temporary file, and return the filename. 1098// 1099 1100function save_text(string $filename, string $text, ?string $filename_copy = null): void 1101{ 1102 global $DETAILED; 1103 1104 if ($filename_copy && $filename_copy != $filename && file_put_contents($filename_copy, $text) === false) { 1105 error("Cannot open file '" . $filename_copy . "' (save_text)"); 1106 } 1107 1108 if (file_put_contents($filename, $text) === false) { 1109 error("Cannot open file '" . $filename . "' (save_text)"); 1110 } 1111 1112 if (1 < $DETAILED) { 1113 echo " 1114FILE $filename {{{ 1115$text 1116}}} 1117"; 1118 } 1119} 1120 1121// 1122// Write an error in a format recognizable to Emacs or MSVC. 1123// 1124 1125function error_report(string $testname, string $logname, string $tested): void 1126{ 1127 $testname = realpath($testname); 1128 $logname = realpath($logname); 1129 1130 switch (strtoupper(getenv('TEST_PHP_ERROR_STYLE'))) { 1131 case 'MSVC': 1132 echo $testname . "(1) : $tested\n"; 1133 echo $logname . "(1) : $tested\n"; 1134 break; 1135 case 'EMACS': 1136 echo $testname . ":1: $tested\n"; 1137 echo $logname . ":1: $tested\n"; 1138 break; 1139 } 1140} 1141 1142/** 1143 * @return false|string 1144 */ 1145function system_with_timeout( 1146 string $commandline, 1147 ?array $env = null, 1148 ?string $stdin = null, 1149 bool $captureStdIn = true, 1150 bool $captureStdOut = true, 1151 bool $captureStdErr = true 1152) { 1153 global $valgrind; 1154 1155 // when proc_open cmd is passed as a string (without bypass_shell=true option) the cmd goes thru shell 1156 // and on Windows quotes are discarded, this is a fix to honor the quotes and allow values containing 1157 // spaces like '"C:\Program Files\PHP\php.exe"' to be passed as 1 argument correctly 1158 if (IS_WINDOWS) { 1159 $commandline = 'start "" /b /wait ' . $commandline . ' & exit'; 1160 } 1161 1162 $data = ''; 1163 1164 $bin_env = []; 1165 foreach ((array) $env as $key => $value) { 1166 $bin_env[$key] = $value; 1167 } 1168 1169 $descriptorspec = []; 1170 if ($captureStdIn) { 1171 $descriptorspec[0] = ['pipe', 'r']; 1172 } 1173 if ($captureStdOut) { 1174 $descriptorspec[1] = ['pipe', 'w']; 1175 } 1176 if ($captureStdErr) { 1177 $descriptorspec[2] = ['pipe', 'w']; 1178 } 1179 $proc = proc_open($commandline, $descriptorspec, $pipes, TEST_PHP_SRCDIR, $bin_env, ['suppress_errors' => true]); 1180 1181 if (!$proc) { 1182 return false; 1183 } 1184 1185 if ($captureStdIn) { 1186 if (!is_null($stdin)) { 1187 fwrite($pipes[0], $stdin); 1188 } 1189 fclose($pipes[0]); 1190 unset($pipes[0]); 1191 } 1192 1193 $timeout = $valgrind ? 300 : ($env['TEST_TIMEOUT'] ?? 60); 1194 /* ASAN can cause a ~2-3x slowdown. */ 1195 if (isset($env['SKIP_ASAN'])) { 1196 $timeout *= 3; 1197 } 1198 1199 while (true) { 1200 /* hide errors from interrupted syscalls */ 1201 $r = $pipes; 1202 $w = null; 1203 $e = null; 1204 1205 $n = @stream_select($r, $w, $e, $timeout); 1206 1207 if ($n === false) { 1208 break; 1209 } 1210 1211 if ($n === 0) { 1212 /* timed out */ 1213 $data .= "\n ** ERROR: process timed out **\n"; 1214 proc_terminate($proc, 9); 1215 return $data; 1216 } 1217 1218 if ($n > 0) { 1219 if ($captureStdOut) { 1220 $line = fread($pipes[1], 8192); 1221 } elseif ($captureStdErr) { 1222 $line = fread($pipes[2], 8192); 1223 } else { 1224 $line = ''; 1225 } 1226 if (strlen($line) == 0) { 1227 /* EOF */ 1228 break; 1229 } 1230 $data .= $line; 1231 } 1232 } 1233 1234 $stat = proc_get_status($proc); 1235 1236 if ($stat['signaled']) { 1237 $data .= "\nTermsig=" . $stat['stopsig'] . "\n"; 1238 } 1239 if ($stat["exitcode"] > 128 && $stat["exitcode"] < 160) { 1240 $data .= "\nTermsig=" . ($stat["exitcode"] - 128) . "\n"; 1241 } else if (defined('PHP_WINDOWS_VERSION_MAJOR') && (($stat["exitcode"] >> 28) & 0b1111) === 0b1100) { 1242 // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-erref/87fba13e-bf06-450e-83b1-9241dc81e781 1243 $data .= "\nTermsig=" . $stat["exitcode"] . "\n"; 1244 } 1245 1246 proc_close($proc); 1247 return $data; 1248} 1249 1250function run_all_tests(array $test_files, array $env, ?string $redir_tested = null): void 1251{ 1252 global $test_results, $failed_tests_file, $result_tests_file, $php, $test_idx, $file_cache; 1253 global $preload; 1254 // Parallel testing 1255 global $PHP_FAILED_TESTS, $workers, $workerID, $workerSock; 1256 1257 if ($file_cache !== null || $preload) { 1258 /* Automatically skip opcache tests in --file-cache and --preload mode, 1259 * because opcache generally expects these to run under a default configuration. */ 1260 $test_files = array_filter($test_files, function($test) use($preload) { 1261 if (!is_string($test)) { 1262 return true; 1263 } 1264 if (false !== strpos($test, 'ext/opcache')) { 1265 return false; 1266 } 1267 if ($preload && false !== strpos($test, 'ext/zend_test/tests/observer')) { 1268 return false; 1269 } 1270 return true; 1271 }); 1272 } 1273 1274 /* Ignore -jN if there is only one file to analyze. */ 1275 if ($workers !== null && count($test_files) > 1 && !$workerID) { 1276 run_all_tests_parallel($test_files, $env, $redir_tested); 1277 return; 1278 } 1279 1280 foreach ($test_files as $name) { 1281 if (is_array($name)) { 1282 $index = "# $name[1]: $name[0]"; 1283 1284 if ($redir_tested) { 1285 $name = $name[0]; 1286 } 1287 } elseif ($redir_tested) { 1288 $index = "# $redir_tested: $name"; 1289 } else { 1290 $index = $name; 1291 } 1292 $test_idx++; 1293 1294 if ($workerID) { 1295 $PHP_FAILED_TESTS = ['BORKED' => [], 'FAILED' => [], 'WARNED' => [], 'LEAKED' => [], 'XFAILED' => [], 'XLEAKED' => [], 'SLOW' => []]; 1296 ob_start(); 1297 } 1298 1299 $result = run_test($php, $name, $env); 1300 if ($workerID) { 1301 $resultText = ob_get_clean(); 1302 } 1303 1304 if (!is_array($name) && $result != 'REDIR') { 1305 if ($workerID) { 1306 send_message($workerSock, [ 1307 "type" => "test_result", 1308 "name" => $name, 1309 "index" => $index, 1310 "result" => $result, 1311 "text" => $resultText, 1312 "PHP_FAILED_TESTS" => $PHP_FAILED_TESTS 1313 ]); 1314 continue; 1315 } 1316 1317 $test_results[$index] = $result; 1318 if ($failed_tests_file && ($result == 'XFAILED' || $result == 'XLEAKED' || $result == 'FAILED' || $result == 'WARNED' || $result == 'LEAKED')) { 1319 fwrite($failed_tests_file, "$index\n"); 1320 } 1321 if ($result_tests_file) { 1322 fwrite($result_tests_file, "$result\t$index\n"); 1323 } 1324 } 1325 } 1326} 1327 1328function run_all_tests_parallel(array $test_files, array $env, ?string $redir_tested): void 1329{ 1330 global $workers, $test_idx, $test_results, $failed_tests_file, $result_tests_file, $PHP_FAILED_TESTS, $shuffle, $valgrind, $show_progress; 1331 1332 global $junit; 1333 1334 // The PHP binary running run-tests.php, and run-tests.php itself 1335 // This PHP executable is *not* necessarily the same as the tested version 1336 $thisPHP = PHP_BINARY; 1337 $thisScript = __FILE__; 1338 1339 $workerProcs = []; 1340 $workerSocks = []; 1341 1342 // Each test may specify a list of conflict keys. While a test that conflicts with 1343 // key K is running, no other test that conflicts with K may run. Conflict keys are 1344 // specified either in the --CONFLICTS-- section, or CONFLICTS file inside a directory. 1345 $dirConflictsWith = []; 1346 $fileConflictsWith = []; 1347 $sequentialTests = []; 1348 foreach ($test_files as $i => $file) { 1349 $contents = file_get_contents($file); 1350 if (preg_match('/^--CONFLICTS--(.+?)^--/ms', $contents, $matches)) { 1351 $conflicts = parse_conflicts($matches[1]); 1352 } else { 1353 // Cache per-directory conflicts in a separate map, so we compute these only once. 1354 $dir = dirname($file); 1355 if (!isset($dirConflictsWith[$dir])) { 1356 $dirConflicts = []; 1357 if (file_exists($dir . '/CONFLICTS')) { 1358 $contents = file_get_contents($dir . '/CONFLICTS'); 1359 $dirConflicts = parse_conflicts($contents); 1360 } 1361 $dirConflictsWith[$dir] = $dirConflicts; 1362 } 1363 $conflicts = $dirConflictsWith[$dir]; 1364 } 1365 1366 // For tests conflicting with "all", no other tests may run in parallel. We'll run these 1367 // tests separately at the end, when only one worker is left. 1368 if (in_array('all', $conflicts, true)) { 1369 $sequentialTests[] = $file; 1370 unset($test_files[$i]); 1371 } 1372 1373 $fileConflictsWith[$file] = $conflicts; 1374 } 1375 1376 // Some tests assume that they are executed in a certain order. We will be popping from 1377 // $test_files, so reverse its order here. This makes sure that order is preserved at least 1378 // for tests with a common conflict key. 1379 $test_files = array_reverse($test_files); 1380 1381 // To discover parallelization issues it is useful to randomize the test order. 1382 if ($shuffle) { 1383 shuffle($test_files); 1384 } 1385 1386 // Don't start more workers than test files. 1387 $workers = max(1, min($workers, count($test_files))); 1388 1389 echo "Spawning $workers workers... "; 1390 1391 // We use sockets rather than STDIN/STDOUT for comms because on Windows, 1392 // those can't be non-blocking for some reason. 1393 $listenSock = stream_socket_server("tcp://127.0.0.1:0") or error("Couldn't create socket on localhost."); 1394 $sockName = stream_socket_get_name($listenSock, false); 1395 // PHP is terrible and returns IPv6 addresses not enclosed by [] 1396 $portPos = strrpos($sockName, ":"); 1397 $sockHost = substr($sockName, 0, $portPos); 1398 if (false !== strpos($sockHost, ":")) { 1399 $sockHost = "[$sockHost]"; 1400 } 1401 $sockPort = substr($sockName, $portPos + 1); 1402 $sockUri = "tcp://$sockHost:$sockPort"; 1403 $totalFileCount = count($test_files); 1404 1405 $startTime = microtime(true); 1406 for ($i = 1; $i <= $workers; $i++) { 1407 $proc = proc_open( 1408 [$thisPHP, $thisScript], 1409 [], // Inherit our stdin, stdout and stderr 1410 $pipes, 1411 null, 1412 $GLOBALS['environment'] + [ 1413 "TEST_PHP_WORKER" => $i, 1414 "TEST_PHP_URI" => $sockUri, 1415 ], 1416 [ 1417 "suppress_errors" => true, 1418 'create_new_console' => true, 1419 ] 1420 ); 1421 if ($proc === false) { 1422 kill_children($workerProcs); 1423 error("Failed to spawn worker $i"); 1424 } 1425 $workerProcs[$i] = $proc; 1426 } 1427 1428 for ($i = 1; $i <= $workers; $i++) { 1429 $workerSock = stream_socket_accept($listenSock, 5); 1430 if ($workerSock === false) { 1431 kill_children($workerProcs); 1432 error("Failed to accept connection from worker."); 1433 } 1434 1435 $greeting = base64_encode(serialize([ 1436 "type" => "hello", 1437 "GLOBALS" => $GLOBALS, 1438 "constants" => [ 1439 "INIT_DIR" => INIT_DIR, 1440 "TEST_PHP_SRCDIR" => TEST_PHP_SRCDIR, 1441 ] 1442 ])) . "\n"; 1443 1444 stream_set_timeout($workerSock, 5); 1445 if (fwrite($workerSock, $greeting) === false) { 1446 kill_children($workerProcs); 1447 error("Failed to send greeting to worker."); 1448 } 1449 1450 $rawReply = fgets($workerSock); 1451 if ($rawReply === false) { 1452 kill_children($workerProcs); 1453 error("Failed to read greeting reply from worker."); 1454 } 1455 1456 $reply = unserialize(base64_decode($rawReply)); 1457 if (!$reply || $reply["type"] !== "hello_reply") { 1458 kill_children($workerProcs); 1459 error("Greeting reply from worker unexpected or could not be decoded: '$rawReply'"); 1460 } 1461 1462 stream_set_timeout($workerSock, 0); 1463 stream_set_blocking($workerSock, false); 1464 1465 $workerID = $reply["workerID"]; 1466 $workerSocks[$workerID] = $workerSock; 1467 } 1468 printf("Done in %.2fs\n", microtime(true) - $startTime); 1469 echo "=====================================================================\n"; 1470 echo "\n"; 1471 1472 $rawMessageBuffers = []; 1473 $testsInProgress = 0; 1474 1475 // Map from conflict key to worker ID. 1476 $activeConflicts = []; 1477 // Tests waiting due to conflicts. Map from conflict key to array. 1478 $waitingTests = []; 1479 1480escape: 1481 while ($test_files || $sequentialTests || $testsInProgress > 0) { 1482 $toRead = array_values($workerSocks); 1483 $toWrite = null; 1484 $toExcept = null; 1485 if (stream_select($toRead, $toWrite, $toExcept, 10)) { 1486 foreach ($toRead as $workerSock) { 1487 $i = array_search($workerSock, $workerSocks); 1488 if ($i === false) { 1489 kill_children($workerProcs); 1490 error("Could not find worker stdout in array of worker stdouts, THIS SHOULD NOT HAPPEN."); 1491 } 1492 if (feof($workerSock)) { 1493 kill_children($workerProcs); 1494 error("Worker $i died unexpectedly"); 1495 } 1496 while (false !== ($rawMessage = fgets($workerSock))) { 1497 // work around fgets truncating things 1498 if (($rawMessageBuffers[$i] ?? '') !== '') { 1499 $rawMessage = $rawMessageBuffers[$i] . $rawMessage; 1500 $rawMessageBuffers[$i] = ''; 1501 } 1502 if (substr($rawMessage, -1) !== "\n") { 1503 $rawMessageBuffers[$i] = $rawMessage; 1504 continue; 1505 } 1506 1507 $message = unserialize(base64_decode($rawMessage)); 1508 if (!$message) { 1509 kill_children($workerProcs); 1510 $stuff = fread($workerSock, 65536); 1511 error("Could not decode message from worker $i: '$rawMessage$stuff'"); 1512 } 1513 1514 switch ($message["type"]) { 1515 case "tests_finished": 1516 $testsInProgress--; 1517 foreach ($activeConflicts as $key => $workerId) { 1518 if ($workerId === $i) { 1519 unset($activeConflicts[$key]); 1520 if (isset($waitingTests[$key])) { 1521 while ($test = array_pop($waitingTests[$key])) { 1522 $test_files[] = $test; 1523 } 1524 unset($waitingTests[$key]); 1525 } 1526 } 1527 } 1528 $junit->mergeResults($message["junit"]); 1529 // no break 1530 case "ready": 1531 // Schedule sequential tests only once we are down to one worker. 1532 if (count($workerProcs) === 1 && $sequentialTests) { 1533 $test_files = array_merge($test_files, $sequentialTests); 1534 $sequentialTests = []; 1535 } 1536 // Batch multiple tests to reduce communication overhead. 1537 // - When valgrind is used, communication overhead is relatively small, 1538 // so just use a batch size of 1. 1539 // - If this is running a small enough number of tests, 1540 // reduce the batch size to give batches to more workers. 1541 $files = []; 1542 $maxBatchSize = $valgrind ? 1 : ($shuffle ? 4 : 32); 1543 $averageFilesPerWorker = max(1, (int) ceil($totalFileCount / count($workerProcs))); 1544 $batchSize = min($maxBatchSize, $averageFilesPerWorker); 1545 while (count($files) <= $batchSize && $file = array_pop($test_files)) { 1546 foreach ($fileConflictsWith[$file] as $conflictKey) { 1547 if (isset($activeConflicts[$conflictKey])) { 1548 $waitingTests[$conflictKey][] = $file; 1549 continue 2; 1550 } 1551 } 1552 $files[] = $file; 1553 } 1554 if ($files) { 1555 foreach ($files as $file) { 1556 foreach ($fileConflictsWith[$file] as $conflictKey) { 1557 $activeConflicts[$conflictKey] = $i; 1558 } 1559 } 1560 $testsInProgress++; 1561 send_message($workerSocks[$i], [ 1562 "type" => "run_tests", 1563 "test_files" => $files, 1564 "env" => $env, 1565 "redir_tested" => $redir_tested 1566 ]); 1567 } else { 1568 proc_terminate($workerProcs[$i]); 1569 unset($workerProcs[$i], $workerSocks[$i]); 1570 goto escape; 1571 } 1572 break; 1573 case "test_result": 1574 list($name, $index, $result, $resultText) = [$message["name"], $message["index"], $message["result"], $message["text"]]; 1575 foreach ($message["PHP_FAILED_TESTS"] as $category => $tests) { 1576 $PHP_FAILED_TESTS[$category] = array_merge($PHP_FAILED_TESTS[$category], $tests); 1577 } 1578 $test_idx++; 1579 1580 if ($show_progress) { 1581 clear_show_test(); 1582 } 1583 1584 echo $resultText; 1585 1586 if ($show_progress) { 1587 show_test($test_idx, count($workerProcs) . "/$workers concurrent test workers running"); 1588 } 1589 1590 if (!is_array($name) && $result != 'REDIR') { 1591 $test_results[$index] = $result; 1592 1593 if ($failed_tests_file && ($result == 'XFAILED' || $result == 'XLEAKED' || $result == 'FAILED' || $result == 'WARNED' || $result == 'LEAKED')) { 1594 fwrite($failed_tests_file, "$index\n"); 1595 } 1596 if ($result_tests_file) { 1597 fwrite($result_tests_file, "$result\t$index\n"); 1598 } 1599 } 1600 break; 1601 case "error": 1602 kill_children($workerProcs); 1603 error("Worker $i reported error: $message[msg]"); 1604 break; 1605 case "php_error": 1606 kill_children($workerProcs); 1607 $error_consts = [ 1608 'E_ERROR', 1609 'E_WARNING', 1610 'E_PARSE', 1611 'E_NOTICE', 1612 'E_CORE_ERROR', 1613 'E_CORE_WARNING', 1614 'E_COMPILE_ERROR', 1615 'E_COMPILE_WARNING', 1616 'E_USER_ERROR', 1617 'E_USER_WARNING', 1618 'E_USER_NOTICE', 1619 'E_RECOVERABLE_ERROR', 1620 'E_DEPRECATED', 1621 'E_USER_DEPRECATED' 1622 ]; 1623 $error_consts = array_combine(array_map('constant', $error_consts), $error_consts); 1624 error("Worker $i reported unexpected {$error_consts[$message['errno']]}: $message[errstr] in $message[errfile] on line $message[errline]"); 1625 // no break 1626 default: 1627 kill_children($workerProcs); 1628 error("Unrecognised message type '$message[type]' from worker $i"); 1629 } 1630 } 1631 } 1632 } 1633 } 1634 1635 if ($show_progress) { 1636 clear_show_test(); 1637 } 1638 1639 kill_children($workerProcs); 1640 1641 if ($testsInProgress < 0) { 1642 error("$testsInProgress test batches “in progress”, which is less than zero. THIS SHOULD NOT HAPPEN."); 1643 } 1644} 1645 1646/** 1647 * Calls fwrite and retries when network writes fail with errors such as "Resource temporarily unavailable" 1648 * 1649 * @param resource $stream the stream to fwrite to 1650 * @param string $data 1651 * @return int|false 1652 */ 1653function safe_fwrite($stream, string $data) 1654{ 1655 // safe_fwrite was tested by adding $message['unused'] = str_repeat('a', 20_000_000); in send_message() 1656 // fwrites on tcp sockets can return false or less than strlen if the recipient is busy. 1657 // (e.g. fwrite(): Send of 577 bytes failed with errno=35 Resource temporarily unavailable) 1658 $bytes_written = 0; 1659 while ($bytes_written < strlen($data)) { 1660 $n = @fwrite($stream, substr($data, $bytes_written)); 1661 if ($n === false) { 1662 $write_streams = [$stream]; 1663 $read_streams = []; 1664 $except_streams = []; 1665 /* Wait for up to 10 seconds for the stream to be ready to write again. */ 1666 $result = stream_select($read_streams, $write_streams, $except_streams, 10); 1667 if (!$result) { 1668 echo "ERROR: send_message() stream_select() failed\n"; 1669 return false; 1670 } 1671 $n = @fwrite($stream, substr($data, $bytes_written)); 1672 if ($n === false) { 1673 echo "ERROR: send_message() Failed to write chunk after stream_select: " . error_get_last()['message'] . "\n"; 1674 return false; 1675 } 1676 } 1677 $bytes_written += $n; 1678 } 1679 return $bytes_written; 1680} 1681 1682function send_message($stream, array $message): void 1683{ 1684 $blocking = stream_get_meta_data($stream)["blocked"]; 1685 stream_set_blocking($stream, true); 1686 safe_fwrite($stream, base64_encode(serialize($message)) . "\n"); 1687 stream_set_blocking($stream, $blocking); 1688} 1689 1690function kill_children(array $children): void 1691{ 1692 foreach ($children as $child) { 1693 if ($child) { 1694 proc_terminate($child); 1695 } 1696 } 1697} 1698 1699function run_worker(): void 1700{ 1701 global $workerID, $workerSock; 1702 1703 global $junit; 1704 1705 $sockUri = getenv("TEST_PHP_URI"); 1706 1707 $workerSock = stream_socket_client($sockUri, $_, $_, 5) or error("Couldn't connect to $sockUri"); 1708 1709 $greeting = fgets($workerSock); 1710 $greeting = unserialize(base64_decode($greeting)) or die("Could not decode greeting\n"); 1711 if ($greeting["type"] !== "hello") { 1712 error("Unexpected greeting of type $greeting[type]"); 1713 } 1714 1715 set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) use ($workerSock): bool { 1716 if (error_reporting() & $errno) { 1717 send_message($workerSock, compact('errno', 'errstr', 'errfile', 'errline') + [ 1718 'type' => 'php_error' 1719 ]); 1720 } 1721 1722 return true; 1723 }); 1724 1725 foreach ($greeting["GLOBALS"] as $var => $value) { 1726 if ($var !== "workerID" && $var !== "workerSock" && $var !== "GLOBALS") { 1727 $GLOBALS[$var] = $value; 1728 } 1729 } 1730 foreach ($greeting["constants"] as $const => $value) { 1731 define($const, $value); 1732 } 1733 1734 send_message($workerSock, [ 1735 "type" => "hello_reply", 1736 "workerID" => $workerID 1737 ]); 1738 1739 send_message($workerSock, [ 1740 "type" => "ready" 1741 ]); 1742 1743 while (($command = fgets($workerSock))) { 1744 $command = unserialize(base64_decode($command)); 1745 1746 switch ($command["type"]) { 1747 case "run_tests": 1748 run_all_tests($command["test_files"], $command["env"], $command["redir_tested"]); 1749 send_message($workerSock, [ 1750 "type" => "tests_finished", 1751 "junit" => $junit->isEnabled() ? $junit : null, 1752 ]); 1753 $junit->clear(); 1754 break; 1755 default: 1756 send_message($workerSock, [ 1757 "type" => "error", 1758 "msg" => "Unrecognised message type: $command[type]" 1759 ]); 1760 break 2; 1761 } 1762 } 1763} 1764 1765// 1766// Show file or result block 1767// 1768function show_file_block(string $file, string $block, ?string $section = null): void 1769{ 1770 global $cfg; 1771 global $colorize; 1772 1773 if ($cfg['show'][$file]) { 1774 if (is_null($section)) { 1775 $section = strtoupper($file); 1776 } 1777 if ($section === 'DIFF' && $colorize) { 1778 // '-' is Light Red for removal, '+' is Light Green for addition 1779 $block = preg_replace('/^[0-9]+\-\s.*$/m', "\e[1;31m\\0\e[0m", $block); 1780 $block = preg_replace('/^[0-9]+\+\s.*$/m', "\e[1;32m\\0\e[0m", $block); 1781 } 1782 1783 echo "\n========" . $section . "========\n"; 1784 echo rtrim($block); 1785 echo "\n========DONE========\n"; 1786 } 1787} 1788 1789function skip_test(string $tested, string $tested_file, string $shortname, string $reason): string 1790{ 1791 global $junit; 1792 1793 show_result('SKIP', $tested, $tested_file, "reason: $reason"); 1794 $junit->initSuite($junit->getSuiteName($shortname)); 1795 $junit->markTestAs('SKIP', $shortname, $tested, 0, $reason); 1796 return 'SKIPPED'; 1797} 1798 1799// 1800// Run an individual test case. 1801// 1802/** 1803 * @param string|array $file 1804 */ 1805function run_test(string $php, $file, array $env): string 1806{ 1807 global $log_format, $ini_overwrites, $PHP_FAILED_TESTS; 1808 global $pass_options, $DETAILED, $IN_REDIRECT, $test_cnt, $test_idx; 1809 global $valgrind, $temp_source, $temp_target, $cfg, $environment; 1810 global $no_clean; 1811 global $SHOW_ONLY_GROUPS; 1812 global $no_file_cache; 1813 global $slow_min_ms; 1814 global $preload, $file_cache; 1815 global $num_repeats; 1816 // Parallel testing 1817 global $workerID; 1818 global $show_progress; 1819 1820 // Temporary 1821 /** @var JUnit $junit */ 1822 global $junit; 1823 1824 static $skipCache; 1825 if (!$skipCache) { 1826 $enableSkipCache = !($env['DISABLE_SKIP_CACHE'] ?? '0'); 1827 $skipCache = new SkipCache($enableSkipCache, $cfg['keep']['skip']); 1828 } 1829 1830 $orig_php = $php; 1831 $php = escapeshellarg($php); 1832 1833 $retried = false; 1834retry: 1835 1836 $org_file = $file; 1837 1838 $php_cgi = $env['TEST_PHP_CGI_EXECUTABLE'] ?? null; 1839 $phpdbg = $env['TEST_PHPDBG_EXECUTABLE'] ?? null; 1840 1841 if (is_array($file)) { 1842 $file = $file[0]; 1843 } 1844 1845 if ($DETAILED) { 1846 echo " 1847================= 1848TEST $file 1849"; 1850 } 1851 1852 $shortname = str_replace(TEST_PHP_SRCDIR . '/', '', $file); 1853 $tested_file = $shortname; 1854 1855 try { 1856 $test = new TestFile($file, (bool)$IN_REDIRECT); 1857 } catch (BorkageException $ex) { 1858 show_result("BORK", $ex->getMessage(), $tested_file); 1859 $PHP_FAILED_TESTS['BORKED'][] = [ 1860 'name' => $file, 1861 'test_name' => '', 1862 'output' => '', 1863 'diff' => '', 1864 'info' => "{$ex->getMessage()} [$file]", 1865 ]; 1866 1867 $junit->markTestAs('BORK', $shortname, $tested_file, 0, $ex->getMessage()); 1868 return 'BORKED'; 1869 } 1870 1871 $tested = $test->getName(); 1872 1873 if ($test->hasSection('FILE_EXTERNAL')) { 1874 if ($num_repeats > 1) { 1875 return skip_test($tested, $tested_file, $shortname, 'Test with FILE_EXTERNAL might not be repeatable'); 1876 } 1877 } 1878 1879 if ($test->hasSection('CAPTURE_STDIO')) { 1880 $capture = $test->getSection('CAPTURE_STDIO'); 1881 $captureStdIn = stripos($capture, 'STDIN') !== false; 1882 $captureStdOut = stripos($capture, 'STDOUT') !== false; 1883 $captureStdErr = stripos($capture, 'STDERR') !== false; 1884 } else { 1885 $captureStdIn = true; 1886 $captureStdOut = true; 1887 $captureStdErr = true; 1888 } 1889 if ($captureStdOut && $captureStdErr) { 1890 $cmdRedirect = ' 2>&1'; 1891 } else { 1892 $cmdRedirect = ''; 1893 } 1894 1895 /* For GET/POST/PUT tests, check if cgi sapi is available and if it is, use it. */ 1896 $uses_cgi = false; 1897 if ($test->isCGI()) { 1898 if (!$php_cgi) { 1899 return skip_test($tested, $tested_file, $shortname, 'CGI not available'); 1900 } 1901 $php = escapeshellarg($php_cgi) . ' -C '; 1902 $uses_cgi = true; 1903 if ($num_repeats > 1) { 1904 return skip_test($tested, $tested_file, $shortname, 'CGI does not support --repeat'); 1905 } 1906 } 1907 1908 /* For phpdbg tests, check if phpdbg sapi is available and if it is, use it. */ 1909 $extra_options = ''; 1910 if ($test->hasSection('PHPDBG')) { 1911 if (isset($phpdbg)) { 1912 $php = escapeshellarg($phpdbg) . ' -qIb'; 1913 1914 // Additional phpdbg command line options for sections that need to 1915 // be run straight away. For example, EXTENSIONS, SKIPIF, CLEAN. 1916 $extra_options = '-rr'; 1917 } else { 1918 return skip_test($tested, $tested_file, $shortname, 'phpdbg not available'); 1919 } 1920 if ($num_repeats > 1) { 1921 return skip_test($tested, $tested_file, $shortname, 'phpdbg does not support --repeat'); 1922 } 1923 } 1924 1925 foreach (['CLEAN', 'STDIN', 'CAPTURE_STDIO'] as $section) { 1926 if ($test->hasSection($section)) { 1927 if ($num_repeats > 1) { 1928 return skip_test($tested, $tested_file, $shortname, "Test with $section might not be repeatable"); 1929 } 1930 } 1931 } 1932 1933 if ($show_progress && !$workerID) { 1934 show_test($test_idx, $shortname); 1935 } 1936 1937 if (is_array($IN_REDIRECT)) { 1938 $temp_dir = $test_dir = $IN_REDIRECT['dir']; 1939 } else { 1940 $temp_dir = $test_dir = realpath(dirname($file)); 1941 } 1942 1943 if ($temp_source && $temp_target) { 1944 $temp_dir = str_replace($temp_source, $temp_target, $temp_dir); 1945 } 1946 1947 $main_file_name = basename($file, 'phpt'); 1948 1949 $diff_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'diff'; 1950 $log_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'log'; 1951 $exp_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'exp'; 1952 $output_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'out'; 1953 $memcheck_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'mem'; 1954 $sh_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'sh'; 1955 $temp_file = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'php'; 1956 $test_file = $test_dir . DIRECTORY_SEPARATOR . $main_file_name . 'php'; 1957 $temp_skipif = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'skip.php'; 1958 $test_skipif = $test_dir . DIRECTORY_SEPARATOR . $main_file_name . 'skip.php'; 1959 $temp_clean = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'clean.php'; 1960 $test_clean = $test_dir . DIRECTORY_SEPARATOR . $main_file_name . 'clean.php'; 1961 $preload_filename = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'preload.php'; 1962 $tmp_post = $temp_dir . DIRECTORY_SEPARATOR . $main_file_name . 'post'; 1963 $tmp_relative_file = str_replace(__DIR__ . DIRECTORY_SEPARATOR, '', $test_file) . 't'; 1964 1965 if ($temp_source && $temp_target) { 1966 $temp_skipif .= 's'; 1967 $temp_file .= 's'; 1968 $temp_clean .= 's'; 1969 $copy_file = $temp_dir . DIRECTORY_SEPARATOR . basename($file) . '.phps'; 1970 1971 if (!is_dir(dirname($copy_file))) { 1972 mkdir(dirname($copy_file), 0777, true) or error("Cannot create output directory - " . dirname($copy_file)); 1973 } 1974 1975 if ($test->hasSection('FILE')) { 1976 save_text($copy_file, $test->getSection('FILE')); 1977 } 1978 } 1979 1980 if (is_array($IN_REDIRECT)) { 1981 $tested = $IN_REDIRECT['prefix'] . ' ' . $tested; 1982 $tested_file = $tmp_relative_file; 1983 $shortname = str_replace(TEST_PHP_SRCDIR . '/', '', $tested_file); 1984 } 1985 1986 // unlink old test results 1987 @unlink($diff_filename); 1988 @unlink($log_filename); 1989 @unlink($exp_filename); 1990 @unlink($output_filename); 1991 @unlink($memcheck_filename); 1992 @unlink($sh_filename); 1993 @unlink($temp_file); 1994 @unlink($test_file); 1995 @unlink($temp_skipif); 1996 @unlink($test_skipif); 1997 @unlink($tmp_post); 1998 @unlink($temp_clean); 1999 @unlink($test_clean); 2000 @unlink($preload_filename); 2001 2002 // Reset environment from any previous test. 2003 $env['REDIRECT_STATUS'] = ''; 2004 $env['QUERY_STRING'] = ''; 2005 $env['PATH_TRANSLATED'] = ''; 2006 $env['SCRIPT_FILENAME'] = ''; 2007 $env['REQUEST_METHOD'] = ''; 2008 $env['CONTENT_TYPE'] = ''; 2009 $env['CONTENT_LENGTH'] = ''; 2010 $env['TZ'] = ''; 2011 2012 if ($test->sectionNotEmpty('ENV')) { 2013 $env_str = str_replace('{PWD}', dirname($file), $test->getSection('ENV')); 2014 foreach (explode("\n", $env_str) as $e) { 2015 $e = explode('=', trim($e), 2); 2016 2017 if (!empty($e[0]) && isset($e[1])) { 2018 $env[$e[0]] = $e[1]; 2019 } 2020 } 2021 } 2022 2023 // Default ini settings 2024 $ini_settings = $workerID ? ['opcache.cache_id' => "worker$workerID"] : []; 2025 2026 // Additional required extensions 2027 $extensions = []; 2028 if ($test->hasSection('EXTENSIONS')) { 2029 $extensions = preg_split("/[\n\r]+/", trim($test->getSection('EXTENSIONS'))); 2030 } 2031 if (is_array($IN_REDIRECT) && $IN_REDIRECT['EXTENSIONS'] != []) { 2032 $extensions = array_merge($extensions, $IN_REDIRECT['EXTENSIONS']); 2033 } 2034 2035 /* Load required extensions */ 2036 if ($extensions != []) { 2037 $ext_params = []; 2038 settings2array($ini_overwrites, $ext_params); 2039 $ext_params = settings2params($ext_params); 2040 [$ext_dir, $loaded] = $skipCache->getExtensions("$orig_php $pass_options $extra_options $ext_params $no_file_cache"); 2041 $ext_prefix = IS_WINDOWS ? "php_" : ""; 2042 $missing = []; 2043 foreach ($extensions as $req_ext) { 2044 if (!in_array($req_ext, $loaded, true)) { 2045 if ($req_ext == 'opcache' || $req_ext == 'xdebug') { 2046 $ext_file = $ext_dir . DIRECTORY_SEPARATOR . $ext_prefix . $req_ext . '.' . PHP_SHLIB_SUFFIX; 2047 $ini_settings['zend_extension'][] = $ext_file; 2048 } else { 2049 $ext_file = $ext_dir . DIRECTORY_SEPARATOR . $ext_prefix . $req_ext . '.' . PHP_SHLIB_SUFFIX; 2050 $ini_settings['extension'][] = $ext_file; 2051 } 2052 if (!is_readable($ext_file)) { 2053 $missing[] = $req_ext; 2054 } 2055 } 2056 } 2057 if ($missing) { 2058 $message = 'Required extension' . (count($missing) > 1 ? 's' : '') 2059 . ' missing: ' . implode(', ', $missing); 2060 return skip_test($tested, $tested_file, $shortname, $message); 2061 } 2062 } 2063 2064 // additional ini overwrites 2065 //$ini_overwrites[] = 'setting=value'; 2066 settings2array($ini_overwrites, $ini_settings); 2067 2068 $orig_ini_settings = settings2params($ini_settings); 2069 2070 if ($file_cache !== null) { 2071 $ini_settings['opcache.file_cache'] = '/tmp'; 2072 // Make sure warnings still show up on the second run. 2073 $ini_settings['opcache.record_warnings'] = '1'; 2074 // File cache is currently incompatible with JIT. 2075 $ini_settings['opcache.jit'] = '0'; 2076 if ($file_cache === 'use') { 2077 // Disable timestamp validation in order to fetch from file cache, 2078 // even though all the files are re-created. 2079 $ini_settings['opcache.validate_timestamps'] = '0'; 2080 } 2081 } else if ($num_repeats > 1) { 2082 // Make sure warnings still show up on the second run. 2083 $ini_settings['opcache.record_warnings'] = '1'; 2084 } 2085 2086 // Any special ini settings 2087 // these may overwrite the test defaults... 2088 if ($test->hasSection('INI')) { 2089 $ini = str_replace('{PWD}', dirname($file), $test->getSection('INI')); 2090 $ini = str_replace('{TMP}', sys_get_temp_dir(), $ini); 2091 $replacement = IS_WINDOWS ? '"' . PHP_BINARY . ' -r \"while ($in = fgets(STDIN)) echo $in;\" > $1"' : 'tee $1 >/dev/null'; 2092 $ini = preg_replace('/{MAIL:(\S+)}/', $replacement, $ini); 2093 $skip = false; 2094 $ini = preg_replace_callback('/{ENV:(\S+)}/', function ($m) use (&$skip) { 2095 $name = $m[1]; 2096 $value = getenv($name); 2097 if ($value === false) { 2098 $skip = sprintf('Environment variable %s is not set', $name); 2099 return ''; 2100 } 2101 return $value; 2102 }, $ini); 2103 if ($skip !== false) { 2104 return skip_test($tested, $tested_file, $shortname, $skip); 2105 } 2106 settings2array(preg_split("/[\n\r]+/", $ini), $ini_settings); 2107 2108 if (isset($ini_settings['opcache.opt_debug_level'])) { 2109 if ($num_repeats > 1) { 2110 return skip_test($tested, $tested_file, $shortname, 'opt_debug_level tests are not repeatable'); 2111 } 2112 } 2113 } 2114 2115 $ini_settings = settings2params($ini_settings); 2116 2117 $env['TEST_PHP_EXTRA_ARGS'] = $pass_options . ' ' . $ini_settings; 2118 2119 // Check if test should be skipped. 2120 $info = ''; 2121 $warn = false; 2122 2123 if ($test->sectionNotEmpty('SKIPIF')) { 2124 show_file_block('skip', $test->getSection('SKIPIF')); 2125 $extra = !IS_WINDOWS ? 2126 "unset REQUEST_METHOD; unset QUERY_STRING; unset PATH_TRANSLATED; unset SCRIPT_FILENAME; unset REQUEST_METHOD;" : ""; 2127 2128 if ($valgrind) { 2129 $env['USE_ZEND_ALLOC'] = '0'; 2130 $env['ZEND_DONT_UNLOAD_MODULES'] = 1; 2131 } 2132 2133 $junit->startTimer($shortname); 2134 2135 $startTime = microtime(true); 2136 $commandLine = "$extra $php $pass_options $extra_options -q $orig_ini_settings $no_file_cache -d display_errors=1 -d display_startup_errors=0"; 2137 $output = $skipCache->checkSkip($commandLine, $test->getSection('SKIPIF'), $test_skipif, $temp_skipif, $env); 2138 2139 $time = microtime(true) - $startTime; 2140 $junit->stopTimer($shortname); 2141 2142 if ($time > $slow_min_ms / 1000) { 2143 $PHP_FAILED_TESTS['SLOW'][] = [ 2144 'name' => $file, 2145 'test_name' => 'SKIPIF of ' . $tested . " [$tested_file]", 2146 'output' => '', 2147 'diff' => '', 2148 'info' => $time, 2149 ]; 2150 } 2151 2152 if (!$cfg['keep']['skip']) { 2153 @unlink($test_skipif); 2154 } 2155 2156 if (!strncasecmp('skip', $output, 4)) { 2157 if (preg_match('/^skip\s*(.+)/i', $output, $m)) { 2158 show_result('SKIP', $tested, $tested_file, "reason: $m[1]"); 2159 } else { 2160 show_result('SKIP', $tested, $tested_file, ''); 2161 } 2162 2163 $message = !empty($m[1]) ? $m[1] : ''; 2164 $junit->markTestAs('SKIP', $shortname, $tested, null, $message); 2165 return 'SKIPPED'; 2166 } 2167 2168 if (!strncasecmp('info', $output, 4) && preg_match('/^info\s*(.+)/i', $output, $m)) { 2169 $info = " (info: $m[1])"; 2170 } elseif (!strncasecmp('warn', $output, 4) && preg_match('/^warn\s+(.+)/i', $output, $m)) { 2171 $warn = true; /* only if there is a reason */ 2172 $info = " (warn: $m[1])"; 2173 } elseif (!strncasecmp('xfail', $output, 5)) { 2174 // Pretend we have an XFAIL section 2175 $test->setSection('XFAIL', ltrim(substr($output, 5))); 2176 } elseif (!strncasecmp('xleak', $output, 5)) { 2177 // Pretend we have an XLEAK section 2178 $test->setSection('XLEAK', ltrim(substr($output, 5))); 2179 } elseif (!strncasecmp('flaky', $output, 5)) { 2180 // Pretend we have a FLAKY section 2181 $test->setSection('FLAKY', ltrim(substr($output, 5))); 2182 } elseif ($output !== '') { 2183 show_result("BORK", $output, $tested_file, 'reason: invalid output from SKIPIF'); 2184 $PHP_FAILED_TESTS['BORKED'][] = [ 2185 'name' => $file, 2186 'test_name' => '', 2187 'output' => '', 2188 'diff' => '', 2189 'info' => "$output [$file]", 2190 ]; 2191 2192 $junit->markTestAs('BORK', $shortname, $tested, null, $output); 2193 return 'BORKED'; 2194 } 2195 } 2196 2197 if (!extension_loaded("zlib") && $test->hasAnySections("GZIP_POST", "DEFLATE_POST")) { 2198 $message = "ext/zlib required"; 2199 show_result('SKIP', $tested, $tested_file, "reason: $message"); 2200 $junit->markTestAs('SKIP', $shortname, $tested, null, $message); 2201 return 'SKIPPED'; 2202 } 2203 2204 if ($test->hasSection('REDIRECTTEST')) { 2205 $test_files = []; 2206 2207 $IN_REDIRECT = eval($test->getSection('REDIRECTTEST')); 2208 $IN_REDIRECT['via'] = "via [$shortname]\n\t"; 2209 $IN_REDIRECT['dir'] = realpath(dirname($file)); 2210 $IN_REDIRECT['prefix'] = $tested; 2211 $IN_REDIRECT['EXTENSIONS'] = $extensions; 2212 2213 if (!empty($IN_REDIRECT['TESTS'])) { 2214 if (is_array($org_file)) { 2215 $test_files[] = $org_file[1]; 2216 } else { 2217 $GLOBALS['test_files'] = $test_files; 2218 find_files($IN_REDIRECT['TESTS']); 2219 2220 foreach ($GLOBALS['test_files'] as $f) { 2221 $test_files[] = [$f, $file]; 2222 } 2223 } 2224 $test_cnt += count($test_files) - 1; 2225 $test_idx--; 2226 2227 show_redirect_start($IN_REDIRECT['TESTS'], $tested, $tested_file); 2228 2229 // set up environment 2230 $redirenv = array_merge($environment, $IN_REDIRECT['ENV']); 2231 $redirenv['REDIR_TEST_DIR'] = realpath($IN_REDIRECT['TESTS']) . DIRECTORY_SEPARATOR; 2232 2233 usort($test_files, "test_sort"); 2234 run_all_tests($test_files, $redirenv, $tested); 2235 2236 show_redirect_ends($IN_REDIRECT['TESTS'], $tested, $tested_file); 2237 2238 // a redirected test never fails 2239 $IN_REDIRECT = false; 2240 2241 $junit->markTestAs('PASS', $shortname, $tested); 2242 return 'REDIR'; 2243 } 2244 2245 $bork_info = "Redirect info must contain exactly one TEST string to be used as redirect directory."; 2246 show_result("BORK", $bork_info, '', ''); 2247 $PHP_FAILED_TESTS['BORKED'][] = [ 2248 'name' => $file, 2249 'test_name' => '', 2250 'output' => '', 2251 'diff' => '', 2252 'info' => "$bork_info [$file]", 2253 ]; 2254 } 2255 2256 if (is_array($org_file) || $test->hasSection('REDIRECTTEST')) { 2257 if (is_array($org_file)) { 2258 $file = $org_file[0]; 2259 } 2260 2261 $bork_info = "Redirected test did not contain redirection info"; 2262 show_result("BORK", $bork_info, '', ''); 2263 $PHP_FAILED_TESTS['BORKED'][] = [ 2264 'name' => $file, 2265 'test_name' => '', 2266 'output' => '', 2267 'diff' => '', 2268 'info' => "$bork_info [$file]", 2269 ]; 2270 2271 $junit->markTestAs('BORK', $shortname, $tested, null, $bork_info); 2272 2273 return 'BORKED'; 2274 } 2275 2276 // We've satisfied the preconditions - run the test! 2277 if ($test->hasSection('FILE')) { 2278 show_file_block('php', $test->getSection('FILE'), 'TEST'); 2279 save_text($test_file, $test->getSection('FILE'), $temp_file); 2280 } else { 2281 $test_file = ""; 2282 } 2283 2284 if ($test->hasSection('GET')) { 2285 $query_string = trim($test->getSection('GET')); 2286 } else { 2287 $query_string = ''; 2288 } 2289 2290 $env['REDIRECT_STATUS'] = '1'; 2291 if (empty($env['QUERY_STRING'])) { 2292 $env['QUERY_STRING'] = $query_string; 2293 } 2294 if (empty($env['PATH_TRANSLATED'])) { 2295 $env['PATH_TRANSLATED'] = $test_file; 2296 } 2297 if (empty($env['SCRIPT_FILENAME'])) { 2298 $env['SCRIPT_FILENAME'] = $test_file; 2299 } 2300 2301 if ($test->hasSection('COOKIE')) { 2302 $env['HTTP_COOKIE'] = trim($test->getSection('COOKIE')); 2303 } else { 2304 $env['HTTP_COOKIE'] = ''; 2305 } 2306 2307 $args = $test->hasSection('ARGS') ? ' -- ' . $test->getSection('ARGS') : ''; 2308 2309 if ($preload && !empty($test_file)) { 2310 save_text($preload_filename, "<?php opcache_compile_file('$test_file');"); 2311 $local_pass_options = $pass_options; 2312 unset($pass_options); 2313 $pass_options = $local_pass_options; 2314 $pass_options .= " -d opcache.preload=" . $preload_filename; 2315 } 2316 2317 if ($test->sectionNotEmpty('POST_RAW')) { 2318 $post = trim($test->getSection('POST_RAW')); 2319 $raw_lines = explode("\n", $post); 2320 2321 $request = ''; 2322 $started = false; 2323 2324 foreach ($raw_lines as $line) { 2325 if (empty($env['CONTENT_TYPE']) && preg_match('/^Content-Type:(.*)/i', $line, $res)) { 2326 $env['CONTENT_TYPE'] = trim(str_replace("\r", '', $res[1])); 2327 continue; 2328 } 2329 2330 if ($started) { 2331 $request .= "\n"; 2332 } 2333 2334 $started = true; 2335 $request .= $line; 2336 } 2337 2338 $env['CONTENT_LENGTH'] = strlen($request); 2339 if (empty($env['REQUEST_METHOD'])) { 2340 $env['REQUEST_METHOD'] = 'POST'; 2341 } 2342 2343 if (empty($request)) { 2344 $junit->markTestAs('BORK', $shortname, $tested, null, 'empty $request'); 2345 return 'BORKED'; 2346 } 2347 2348 save_text($tmp_post, $request); 2349 $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\""; 2350 } elseif ($test->sectionNotEmpty('PUT')) { 2351 $post = trim($test->getSection('PUT')); 2352 $raw_lines = explode("\n", $post); 2353 2354 $request = ''; 2355 $started = false; 2356 2357 foreach ($raw_lines as $line) { 2358 if (empty($env['CONTENT_TYPE']) && preg_match('/^Content-Type:(.*)/i', $line, $res)) { 2359 $env['CONTENT_TYPE'] = trim(str_replace("\r", '', $res[1])); 2360 continue; 2361 } 2362 2363 if ($started) { 2364 $request .= "\n"; 2365 } 2366 2367 $started = true; 2368 $request .= $line; 2369 } 2370 2371 $env['CONTENT_LENGTH'] = strlen($request); 2372 $env['REQUEST_METHOD'] = 'PUT'; 2373 2374 if (empty($request)) { 2375 $junit->markTestAs('BORK', $shortname, $tested, null, 'empty $request'); 2376 return 'BORKED'; 2377 } 2378 2379 save_text($tmp_post, $request); 2380 $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\""; 2381 } elseif ($test->sectionNotEmpty('POST')) { 2382 $post = trim($test->getSection('POST')); 2383 $content_length = strlen($post); 2384 save_text($tmp_post, $post); 2385 2386 $env['REQUEST_METHOD'] = 'POST'; 2387 if (empty($env['CONTENT_TYPE'])) { 2388 $env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; 2389 } 2390 2391 if (empty($env['CONTENT_LENGTH'])) { 2392 $env['CONTENT_LENGTH'] = $content_length; 2393 } 2394 2395 $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\""; 2396 } elseif ($test->sectionNotEmpty('GZIP_POST')) { 2397 $post = trim($test->getSection('GZIP_POST')); 2398 $post = gzencode($post, 9, FORCE_GZIP); 2399 $env['HTTP_CONTENT_ENCODING'] = 'gzip'; 2400 2401 save_text($tmp_post, $post); 2402 $content_length = strlen($post); 2403 2404 $env['REQUEST_METHOD'] = 'POST'; 2405 $env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; 2406 $env['CONTENT_LENGTH'] = $content_length; 2407 2408 $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\""; 2409 } elseif ($test->sectionNotEmpty('DEFLATE_POST')) { 2410 $post = trim($test->getSection('DEFLATE_POST')); 2411 $post = gzcompress($post, 9); 2412 $env['HTTP_CONTENT_ENCODING'] = 'deflate'; 2413 save_text($tmp_post, $post); 2414 $content_length = strlen($post); 2415 2416 $env['REQUEST_METHOD'] = 'POST'; 2417 $env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; 2418 $env['CONTENT_LENGTH'] = $content_length; 2419 2420 $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\""; 2421 } else { 2422 $env['REQUEST_METHOD'] = 'GET'; 2423 $env['CONTENT_TYPE'] = ''; 2424 $env['CONTENT_LENGTH'] = ''; 2425 2426 $repeat_option = $num_repeats > 1 ? "--repeat $num_repeats" : ""; 2427 $cmd = "$php $pass_options $repeat_option $ini_settings -f \"$test_file\" $args$cmdRedirect"; 2428 } 2429 2430 $orig_cmd = $cmd; 2431 if ($valgrind) { 2432 $env['USE_ZEND_ALLOC'] = '0'; 2433 $env['ZEND_DONT_UNLOAD_MODULES'] = 1; 2434 2435 $cmd = $valgrind->wrapCommand($cmd, $memcheck_filename, strpos($test_file, "pcre") !== false); 2436 } 2437 2438 if ($test->hasSection('XLEAK')) { 2439 $env['ZEND_ALLOC_PRINT_LEAKS'] = '0'; 2440 if (isset($env['SKIP_ASAN'])) { 2441 // $env['LSAN_OPTIONS'] = 'detect_leaks=0'; 2442 /* For unknown reasons, LSAN_OPTIONS=detect_leaks=0 would occasionally not be picked up 2443 * in CI. Skip the test with ASAN, as it's not worth investegating. */ 2444 return skip_test($tested, $tested_file, $shortname, 'xleak does not work with asan'); 2445 } 2446 } 2447 2448 if ($DETAILED) { 2449 echo " 2450CONTENT_LENGTH = " . $env['CONTENT_LENGTH'] . " 2451CONTENT_TYPE = " . $env['CONTENT_TYPE'] . " 2452PATH_TRANSLATED = " . $env['PATH_TRANSLATED'] . " 2453QUERY_STRING = " . $env['QUERY_STRING'] . " 2454REDIRECT_STATUS = " . $env['REDIRECT_STATUS'] . " 2455REQUEST_METHOD = " . $env['REQUEST_METHOD'] . " 2456SCRIPT_FILENAME = " . $env['SCRIPT_FILENAME'] . " 2457HTTP_COOKIE = " . $env['HTTP_COOKIE'] . " 2458COMMAND $cmd 2459"; 2460 } 2461 2462 $junit->startTimer($shortname); 2463 $hrtime = hrtime(); 2464 $startTime = $hrtime[0] * 1000000000 + $hrtime[1]; 2465 2466 $stdin = $test->hasSection('STDIN') ? $test->getSection('STDIN') : null; 2467 $out = system_with_timeout($cmd, $env, $stdin, $captureStdIn, $captureStdOut, $captureStdErr); 2468 2469 $junit->stopTimer($shortname); 2470 $hrtime = hrtime(); 2471 $time = $hrtime[0] * 1000000000 + $hrtime[1] - $startTime; 2472 if ($time >= $slow_min_ms * 1000000) { 2473 $PHP_FAILED_TESTS['SLOW'][] = [ 2474 'name' => $file, 2475 'test_name' => $tested . " [$tested_file]", 2476 'output' => '', 2477 'diff' => '', 2478 'info' => $time / 1000000000, 2479 ]; 2480 } 2481 2482 // Remember CLEAN output to report borked test if it otherwise passes. 2483 $clean_output = null; 2484 if ((!$no_clean || $cfg['keep']['clean']) && $test->sectionNotEmpty('CLEAN')) { 2485 show_file_block('clean', $test->getSection('CLEAN')); 2486 save_text($test_clean, trim($test->getSection('CLEAN')), $temp_clean); 2487 2488 if (!$no_clean) { 2489 $extra = !IS_WINDOWS ? 2490 "unset REQUEST_METHOD; unset QUERY_STRING; unset PATH_TRANSLATED; unset SCRIPT_FILENAME; unset REQUEST_METHOD;" : ""; 2491 $clean_output = system_with_timeout("$extra $orig_php $pass_options -q $orig_ini_settings $no_file_cache \"$test_clean\"", $env); 2492 } 2493 2494 if (!$cfg['keep']['clean']) { 2495 @unlink($test_clean); 2496 } 2497 } 2498 2499 $leaked = false; 2500 $passed = false; 2501 2502 if ($valgrind) { // leak check 2503 $leaked = filesize($memcheck_filename) > 0; 2504 2505 if (!$leaked) { 2506 @unlink($memcheck_filename); 2507 } 2508 } 2509 2510 if ($num_repeats > 1) { 2511 // In repeat mode, retain the output before the first execution, 2512 // and of the last execution. Do this early, because the trimming below 2513 // makes the newline handling complicated. 2514 $separator1 = "Executing for the first time...\n"; 2515 $separator1_pos = strpos($out, $separator1); 2516 if ($separator1_pos !== false) { 2517 $separator2 = "Finished execution, repeating...\n"; 2518 $separator2_pos = strrpos($out, $separator2); 2519 if ($separator2_pos !== false) { 2520 $out = substr($out, 0, $separator1_pos) 2521 . substr($out, $separator2_pos + strlen($separator2)); 2522 } else { 2523 $out = substr($out, 0, $separator1_pos) 2524 . substr($out, $separator1_pos + strlen($separator1)); 2525 } 2526 } 2527 } 2528 2529 // Does the output match what is expected? 2530 $output = preg_replace("/\r\n/", "\n", trim($out)); 2531 2532 /* when using CGI, strip the headers from the output */ 2533 $headers = []; 2534 2535 if ($uses_cgi && preg_match("/^(.*?)\r?\n\r?\n(.*)/s", $out, $match)) { 2536 $output = trim($match[2]); 2537 $rh = preg_split("/[\n\r]+/", $match[1]); 2538 2539 foreach ($rh as $line) { 2540 if (strpos($line, ':') !== false) { 2541 $line = explode(':', $line, 2); 2542 $headers[trim($line[0])] = trim($line[1]); 2543 } 2544 } 2545 } 2546 2547 $wanted_headers = null; 2548 $output_headers = null; 2549 $failed_headers = false; 2550 2551 if ($test->hasSection('EXPECTHEADERS')) { 2552 $want = []; 2553 $wanted_headers = []; 2554 $lines = preg_split("/[\n\r]+/", $test->getSection('EXPECTHEADERS')); 2555 2556 foreach ($lines as $line) { 2557 if (strpos($line, ':') !== false) { 2558 $line = explode(':', $line, 2); 2559 $want[trim($line[0])] = trim($line[1]); 2560 $wanted_headers[] = trim($line[0]) . ': ' . trim($line[1]); 2561 } 2562 } 2563 2564 $output_headers = []; 2565 2566 foreach ($want as $k => $v) { 2567 if (isset($headers[$k])) { 2568 $output_headers[] = $k . ': ' . $headers[$k]; 2569 } 2570 2571 if (!isset($headers[$k]) || $headers[$k] != $v) { 2572 $failed_headers = true; 2573 } 2574 } 2575 2576 $wanted_headers = implode("\n", $wanted_headers); 2577 $output_headers = implode("\n", $output_headers); 2578 } 2579 2580 show_file_block('out', $output); 2581 2582 if ($preload) { 2583 $output = trim(preg_replace("/\n?Warning: Can't preload [^\n]*\n?/", "", $output)); 2584 } 2585 2586 if ($test->hasAnySections('EXPECTF', 'EXPECTREGEX')) { 2587 if ($test->hasSection('EXPECTF')) { 2588 $wanted = trim($test->getSection('EXPECTF')); 2589 } else { 2590 $wanted = trim($test->getSection('EXPECTREGEX')); 2591 } 2592 2593 show_file_block('exp', $wanted); 2594 $wanted_re = preg_replace('/\r\n/', "\n", $wanted); 2595 2596 if ($test->hasSection('EXPECTF')) { 2597 $wanted_re = expectf_to_regex($wanted_re); 2598 } 2599 2600 if (preg_match('/^' . $wanted_re . '$/s', $output)) { 2601 $passed = true; 2602 } 2603 } else { 2604 $wanted = trim($test->getSection('EXPECT')); 2605 $wanted = preg_replace('/\r\n/', "\n", $wanted); 2606 show_file_block('exp', $wanted); 2607 2608 // compare and leave on success 2609 if (!strcmp($output, $wanted)) { 2610 $passed = true; 2611 } 2612 2613 $wanted_re = null; 2614 } 2615 if (!$passed && !$retried && error_may_be_retried($test, $output)) { 2616 $retried = true; 2617 goto retry; 2618 } 2619 2620 if ($passed) { 2621 if (!$cfg['keep']['php'] && !$leaked) { 2622 @unlink($test_file); 2623 @unlink($preload_filename); 2624 } 2625 @unlink($tmp_post); 2626 2627 if (!$leaked && !$failed_headers) { 2628 // If the test passed and CLEAN produced output, report test as borked. 2629 if ($clean_output) { 2630 show_result("BORK", $output, $tested_file, 'reason: invalid output from CLEAN'); 2631 $PHP_FAILED_TESTS['BORKED'][] = [ 2632 'name' => $file, 2633 'test_name' => '', 2634 'output' => '', 2635 'diff' => '', 2636 'info' => "$clean_output [$file]", 2637 ]; 2638 2639 $junit->markTestAs('BORK', $shortname, $tested, null, $clean_output); 2640 return 'BORKED'; 2641 } 2642 2643 if ($test->hasSection('XFAIL')) { 2644 $warn = true; 2645 $info = " (warn: XFAIL section but test passes)"; 2646 } elseif ($test->hasSection('XLEAK') && $valgrind) { 2647 // XLEAK with ASAN completely disables LSAN so the test is expected to pass 2648 $warn = true; 2649 $info = " (warn: XLEAK section but test passes)"; 2650 } elseif ($retried) { 2651 $warn = true; 2652 $info = " (warn: Test passed on retry attempt)"; 2653 } else { 2654 show_result("PASS", $tested, $tested_file, ''); 2655 $junit->markTestAs('PASS', $shortname, $tested); 2656 return 'PASSED'; 2657 } 2658 } 2659 } 2660 2661 // Test failed so we need to report details. 2662 if ($failed_headers) { 2663 $passed = false; 2664 $wanted = $wanted_headers . "\n--HEADERS--\n" . $wanted; 2665 $output = $output_headers . "\n--HEADERS--\n" . $output; 2666 2667 if (isset($wanted_re)) { 2668 $wanted_re = preg_quote($wanted_headers . "\n--HEADERS--\n", '/') . $wanted_re; 2669 } 2670 } 2671 2672 $restype = []; 2673 2674 if ($leaked) { 2675 $restype[] = $test->hasSection('XLEAK') ? 2676 'XLEAK' : 'LEAK'; 2677 } 2678 2679 if ($warn) { 2680 $restype[] = 'WARN'; 2681 } 2682 2683 if (!$passed) { 2684 if ($test->hasSection('XFAIL')) { 2685 $restype[] = 'XFAIL'; 2686 $info = ' XFAIL REASON: ' . rtrim($test->getSection('XFAIL')); 2687 } elseif ($test->hasSection('XLEAK') && $valgrind) { 2688 // XLEAK with ASAN completely disables LSAN so the test is expected to pass 2689 $restype[] = 'XLEAK'; 2690 $info = ' XLEAK REASON: ' . rtrim($test->getSection('XLEAK')); 2691 } else { 2692 $restype[] = 'FAIL'; 2693 } 2694 } 2695 2696 if (!$passed) { 2697 // write .exp 2698 if (strpos($log_format, 'E') !== false && file_put_contents($exp_filename, $wanted) === false) { 2699 error("Cannot create expected test output - $exp_filename"); 2700 } 2701 2702 // write .out 2703 if (strpos($log_format, 'O') !== false && file_put_contents($output_filename, $output) === false) { 2704 error("Cannot create test output - $output_filename"); 2705 } 2706 2707 // write .diff 2708 if (!empty($environment['TEST_PHP_DIFF_CMD'])) { 2709 $diff = generate_diff_external($environment['TEST_PHP_DIFF_CMD'], $exp_filename, $output_filename); 2710 } else { 2711 $diff = generate_diff($wanted, $wanted_re, $output); 2712 } 2713 2714 if (is_array($IN_REDIRECT)) { 2715 $orig_shortname = str_replace(TEST_PHP_SRCDIR . '/', '', $file); 2716 $diff = "# original source file: $orig_shortname\n" . $diff; 2717 } 2718 if (!$SHOW_ONLY_GROUPS || array_intersect($restype, $SHOW_ONLY_GROUPS)) { 2719 show_file_block('diff', $diff); 2720 } 2721 if (strpos($log_format, 'D') !== false && file_put_contents($diff_filename, $diff) === false) { 2722 error("Cannot create test diff - $diff_filename"); 2723 } 2724 2725 // write .log 2726 if (strpos($log_format, 'L') !== false && file_put_contents($log_filename, " 2727---- EXPECTED OUTPUT 2728$wanted 2729---- ACTUAL OUTPUT 2730$output 2731---- FAILED 2732") === false) { 2733 error("Cannot create test log - $log_filename"); 2734 error_report($file, $log_filename, $tested); 2735 } 2736 } 2737 2738 if (!$passed || $leaked) { 2739 // write .sh 2740 if (strpos($log_format, 'S') !== false) { 2741 $env_lines = []; 2742 foreach ($env as $env_var => $env_val) { 2743 $env_lines[] = "export $env_var=" . escapeshellarg($env_val ?? ""); 2744 } 2745 $exported_environment = "\n" . implode("\n", $env_lines) . "\n"; 2746 $sh_script = <<<SH 2747#!/bin/sh 2748{$exported_environment} 2749case "$1" in 2750"gdb") 2751 gdb --args {$orig_cmd} 2752 ;; 2753"lldb") 2754 lldb -- {$orig_cmd} 2755 ;; 2756"valgrind") 2757 USE_ZEND_ALLOC=0 valgrind $2 {$orig_cmd} 2758 ;; 2759"rr") 2760 rr record $2 {$orig_cmd} 2761 ;; 2762*) 2763 {$orig_cmd} 2764 ;; 2765esac 2766SH; 2767 if (file_put_contents($sh_filename, $sh_script) === false) { 2768 error("Cannot create test shell script - $sh_filename"); 2769 } 2770 chmod($sh_filename, 0755); 2771 } 2772 } 2773 2774 if ($valgrind && $leaked && $cfg["show"]["mem"]) { 2775 show_file_block('mem', file_get_contents($memcheck_filename)); 2776 } 2777 2778 show_result(implode('&', $restype), $tested, $tested_file, $info); 2779 2780 foreach ($restype as $type) { 2781 $PHP_FAILED_TESTS[$type . 'ED'][] = [ 2782 'name' => $file, 2783 'test_name' => (is_array($IN_REDIRECT) ? $IN_REDIRECT['via'] : '') . $tested . " [$tested_file]", 2784 'output' => $output_filename, 2785 'diff' => $diff_filename, 2786 'info' => $info, 2787 ]; 2788 } 2789 2790 $diff = empty($diff) ? '' : preg_replace('/\e/', '<esc>', $diff); 2791 2792 $junit->markTestAs($restype, $shortname, $tested, null, $info, $diff); 2793 2794 return $restype[0] . 'ED'; 2795} 2796 2797function is_flaky(TestFile $test): bool 2798{ 2799 if ($test->hasSection('FLAKY')) { 2800 return true; 2801 } 2802 if (!$test->hasSection('FILE')) { 2803 return false; 2804 } 2805 $file = $test->getSection('FILE'); 2806 $flaky_functions = [ 2807 'disk_free_space', 2808 'hrtime', 2809 'microtime', 2810 'sleep', 2811 'usleep', 2812 ]; 2813 $regex = '(\b(' . implode('|', $flaky_functions) . ')\()i'; 2814 return preg_match($regex, $file) === 1; 2815} 2816 2817function is_flaky_output(string $output): bool 2818{ 2819 $messages = [ 2820 '404: page not found', 2821 'address already in use', 2822 'connection refused', 2823 'deadlock', 2824 'mailbox already exists', 2825 'timed out', 2826 ]; 2827 $regex = '(\b(' . implode('|', $messages) . ')\b)i'; 2828 return preg_match($regex, $output) === 1; 2829} 2830 2831function error_may_be_retried(TestFile $test, string $output): bool 2832{ 2833 return is_flaky_output($output) 2834 || is_flaky($test); 2835} 2836 2837function expectf_to_regex(?string $wanted): string 2838{ 2839 $wanted_re = $wanted ?? ''; 2840 2841 $wanted_re = preg_replace('/\r\n/', "\n", $wanted_re); 2842 2843 // do preg_quote, but miss out any %r delimited sections 2844 $temp = ""; 2845 $r = "%r"; 2846 $startOffset = 0; 2847 $length = strlen($wanted_re); 2848 while ($startOffset < $length) { 2849 $start = strpos($wanted_re, $r, $startOffset); 2850 if ($start !== false) { 2851 // we have found a start tag 2852 $end = strpos($wanted_re, $r, $start + 2); 2853 if ($end === false) { 2854 // unbalanced tag, ignore it. 2855 $end = $start = $length; 2856 } 2857 } else { 2858 // no more %r sections 2859 $start = $end = $length; 2860 } 2861 // quote a non re portion of the string 2862 $temp .= preg_quote(substr($wanted_re, $startOffset, $start - $startOffset), '/'); 2863 // add the re unquoted. 2864 if ($end > $start) { 2865 $temp .= '(' . substr($wanted_re, $start + 2, $end - $start - 2) . ')'; 2866 } 2867 $startOffset = $end + 2; 2868 } 2869 $wanted_re = $temp; 2870 2871 return strtr($wanted_re, [ 2872 '%e' => preg_quote(DIRECTORY_SEPARATOR, '/'), 2873 '%s' => '[^\r\n]+', 2874 '%S' => '[^\r\n]*', 2875 '%a' => '.+', 2876 '%A' => '.*', 2877 '%w' => '\s*', 2878 '%i' => '[+-]?\d+', 2879 '%d' => '\d+', 2880 '%x' => '[0-9a-fA-F]+', 2881 '%f' => '[+-]?(?:\d+|(?=\.\d))(?:\.\d+)?(?:[Ee][+-]?\d+)?', 2882 '%c' => '.', 2883 '%0' => '\x00', 2884 ]); 2885} 2886 2887/** 2888 * @return bool|int 2889 */ 2890function comp_line(string $l1, string $l2, bool $is_reg) 2891{ 2892 if ($is_reg) { 2893 return preg_match('/^' . $l1 . '$/s', $l2); 2894 } 2895 2896 return !strcmp($l1, $l2); 2897} 2898 2899/** 2900 * Map "Zend OPcache" to "opcache" and convert all ext names to lowercase. 2901 */ 2902function remap_loaded_extensions_names(array $names): array 2903{ 2904 $exts = []; 2905 foreach ($names as $name) { 2906 if ($name === 'Core') { 2907 continue; 2908 } 2909 $exts[] = ['Zend OPcache' => 'opcache'][$name] ?? strtolower($name); 2910 } 2911 2912 return $exts; 2913} 2914 2915function generate_diff_external(string $diff_cmd, string $exp_file, string $output_file): string 2916{ 2917 $retval = shell_exec("{$diff_cmd} {$exp_file} {$output_file}"); 2918 2919 return is_string($retval) ? $retval : 'Could not run external diff tool set through TEST_PHP_DIFF_CMD environment variable'; 2920} 2921 2922function generate_diff(string $wanted, ?string $wanted_re, string $output): string 2923{ 2924 $w = explode("\n", $wanted); 2925 $o = explode("\n", $output); 2926 $is_regex = $wanted_re !== null; 2927 2928 $differ = new Differ(function ($expected, $new) use ($is_regex) { 2929 if (!$is_regex) { 2930 return $expected === $new; 2931 } 2932 $regex = '/^' . expectf_to_regex($expected). '$/s'; 2933 return preg_match($regex, $new); 2934 }); 2935 return $differ->diff($w, $o); 2936} 2937 2938function error(string $message): void 2939{ 2940 echo "ERROR: {$message}\n"; 2941 exit(1); 2942} 2943 2944function settings2array(array $settings, array &$ini_settings): void 2945{ 2946 foreach ($settings as $setting) { 2947 if (strpos($setting, '=') !== false) { 2948 $setting = explode("=", $setting, 2); 2949 $name = trim($setting[0]); 2950 $value = trim($setting[1]); 2951 2952 if ($name == 'extension' || $name == 'zend_extension') { 2953 if (!isset($ini_settings[$name])) { 2954 $ini_settings[$name] = []; 2955 } 2956 2957 $ini_settings[$name][] = $value; 2958 } else { 2959 $ini_settings[$name] = $value; 2960 } 2961 } 2962 } 2963} 2964 2965function settings2params(array $ini_settings): string 2966{ 2967 $settings = ''; 2968 2969 foreach ($ini_settings as $name => $value) { 2970 if (is_array($value)) { 2971 foreach ($value as $val) { 2972 $val = addslashes($val); 2973 $settings .= " -d \"$name=$val\""; 2974 } 2975 } else { 2976 if (IS_WINDOWS && !empty($value) && $value[0] == '"') { 2977 $len = strlen($value); 2978 2979 if ($value[$len - 1] == '"') { 2980 $value[0] = "'"; 2981 $value[$len - 1] = "'"; 2982 } 2983 } else { 2984 $value = addslashes($value); 2985 } 2986 2987 $settings .= " -d \"$name=$value\""; 2988 } 2989 } 2990 2991 return $settings; 2992} 2993 2994function compute_summary(): void 2995{ 2996 global $n_total, $test_results, $ignored_by_ext, $sum_results, $percent_results; 2997 2998 $n_total = count($test_results); 2999 $n_total += count($ignored_by_ext); 3000 $sum_results = [ 3001 'PASSED' => 0, 3002 'WARNED' => 0, 3003 'SKIPPED' => 0, 3004 'FAILED' => 0, 3005 'BORKED' => 0, 3006 'LEAKED' => 0, 3007 'XFAILED' => 0, 3008 'XLEAKED' => 0 3009 ]; 3010 3011 foreach ($test_results as $v) { 3012 $sum_results[$v]++; 3013 } 3014 3015 $sum_results['SKIPPED'] += count($ignored_by_ext); 3016 $percent_results = []; 3017 3018 foreach ($sum_results as $v => $n) { 3019 $percent_results[$v] = (100.0 * $n) / $n_total; 3020 } 3021} 3022 3023function get_summary(bool $show_ext_summary): string 3024{ 3025 global $exts_skipped, $exts_tested, $n_total, $sum_results, $percent_results, $end_time, $start_time, $failed_test_summary, $PHP_FAILED_TESTS, $valgrind; 3026 3027 $x_total = $n_total - $sum_results['SKIPPED'] - $sum_results['BORKED']; 3028 3029 if ($x_total) { 3030 $x_warned = (100.0 * $sum_results['WARNED']) / $x_total; 3031 $x_failed = (100.0 * $sum_results['FAILED']) / $x_total; 3032 $x_xfailed = (100.0 * $sum_results['XFAILED']) / $x_total; 3033 $x_xleaked = (100.0 * $sum_results['XLEAKED']) / $x_total; 3034 $x_leaked = (100.0 * $sum_results['LEAKED']) / $x_total; 3035 $x_passed = (100.0 * $sum_results['PASSED']) / $x_total; 3036 } else { 3037 $x_warned = $x_failed = $x_passed = $x_leaked = $x_xfailed = $x_xleaked = 0; 3038 } 3039 3040 $summary = ''; 3041 3042 if ($show_ext_summary) { 3043 $summary .= ' 3044===================================================================== 3045TEST RESULT SUMMARY 3046--------------------------------------------------------------------- 3047Exts skipped : ' . sprintf('%5d', count($exts_skipped)) . ($exts_skipped ? ' (' . implode(', ', $exts_skipped) . ')' : '') . ' 3048Exts tested : ' . sprintf('%5d', count($exts_tested)) . ' 3049--------------------------------------------------------------------- 3050'; 3051 } 3052 3053 $summary .= ' 3054Number of tests : ' . sprintf('%5d', $n_total) . ' ' . sprintf('%8d', $x_total); 3055 3056 if ($sum_results['BORKED']) { 3057 $summary .= ' 3058Tests borked : ' . sprintf('%5d (%5.1f%%)', $sum_results['BORKED'], $percent_results['BORKED']) . ' --------'; 3059 } 3060 3061 $summary .= ' 3062Tests skipped : ' . sprintf('%5d (%5.1f%%)', $sum_results['SKIPPED'], $percent_results['SKIPPED']) . ' -------- 3063Tests warned : ' . sprintf('%5d (%5.1f%%)', $sum_results['WARNED'], $percent_results['WARNED']) . ' ' . sprintf('(%5.1f%%)', $x_warned) . ' 3064Tests failed : ' . sprintf('%5d (%5.1f%%)', $sum_results['FAILED'], $percent_results['FAILED']) . ' ' . sprintf('(%5.1f%%)', $x_failed); 3065 3066 if ($sum_results['XFAILED']) { 3067 $summary .= ' 3068Expected fail : ' . sprintf('%5d (%5.1f%%)', $sum_results['XFAILED'], $percent_results['XFAILED']) . ' ' . sprintf('(%5.1f%%)', $x_xfailed); 3069 } 3070 3071 if ($valgrind) { 3072 $summary .= ' 3073Tests leaked : ' . sprintf('%5d (%5.1f%%)', $sum_results['LEAKED'], $percent_results['LEAKED']) . ' ' . sprintf('(%5.1f%%)', $x_leaked); 3074 if ($sum_results['XLEAKED']) { 3075 $summary .= ' 3076Expected leak : ' . sprintf('%5d (%5.1f%%)', $sum_results['XLEAKED'], $percent_results['XLEAKED']) . ' ' . sprintf('(%5.1f%%)', $x_xleaked); 3077 } 3078 } 3079 3080 $summary .= ' 3081Tests passed : ' . sprintf('%5d (%5.1f%%)', $sum_results['PASSED'], $percent_results['PASSED']) . ' ' . sprintf('(%5.1f%%)', $x_passed) . ' 3082--------------------------------------------------------------------- 3083Time taken : ' . sprintf('%5.3f seconds', ($end_time - $start_time) / 1e9) . ' 3084===================================================================== 3085'; 3086 $failed_test_summary = ''; 3087 3088 if (count($PHP_FAILED_TESTS['SLOW'])) { 3089 usort($PHP_FAILED_TESTS['SLOW'], function (array $a, array $b): int { 3090 return $a['info'] < $b['info'] ? 1 : -1; 3091 }); 3092 3093 $failed_test_summary .= ' 3094===================================================================== 3095SLOW TEST SUMMARY 3096--------------------------------------------------------------------- 3097'; 3098 foreach ($PHP_FAILED_TESTS['SLOW'] as $failed_test_data) { 3099 $failed_test_summary .= sprintf('(%.3f s) ', $failed_test_data['info']) . $failed_test_data['test_name'] . "\n"; 3100 } 3101 $failed_test_summary .= "=====================================================================\n"; 3102 } 3103 3104 if (count($PHP_FAILED_TESTS['BORKED'])) { 3105 $failed_test_summary .= ' 3106===================================================================== 3107BORKED TEST SUMMARY 3108--------------------------------------------------------------------- 3109'; 3110 foreach ($PHP_FAILED_TESTS['BORKED'] as $failed_test_data) { 3111 $failed_test_summary .= $failed_test_data['info'] . "\n"; 3112 } 3113 3114 $failed_test_summary .= "=====================================================================\n"; 3115 } 3116 3117 if (count($PHP_FAILED_TESTS['FAILED'])) { 3118 $failed_test_summary .= ' 3119===================================================================== 3120FAILED TEST SUMMARY 3121--------------------------------------------------------------------- 3122'; 3123 foreach ($PHP_FAILED_TESTS['FAILED'] as $failed_test_data) { 3124 $failed_test_summary .= $failed_test_data['test_name'] . $failed_test_data['info'] . "\n"; 3125 } 3126 $failed_test_summary .= "=====================================================================\n"; 3127 } 3128 if (count($PHP_FAILED_TESTS['WARNED'])) { 3129 $failed_test_summary .= ' 3130===================================================================== 3131WARNED TEST SUMMARY 3132--------------------------------------------------------------------- 3133'; 3134 foreach ($PHP_FAILED_TESTS['WARNED'] as $failed_test_data) { 3135 $failed_test_summary .= $failed_test_data['test_name'] . $failed_test_data['info'] . "\n"; 3136 } 3137 3138 $failed_test_summary .= "=====================================================================\n"; 3139 } 3140 3141 if (count($PHP_FAILED_TESTS['LEAKED'])) { 3142 $failed_test_summary .= ' 3143===================================================================== 3144LEAKED TEST SUMMARY 3145--------------------------------------------------------------------- 3146'; 3147 foreach ($PHP_FAILED_TESTS['LEAKED'] as $failed_test_data) { 3148 $failed_test_summary .= $failed_test_data['test_name'] . $failed_test_data['info'] . "\n"; 3149 } 3150 3151 $failed_test_summary .= "=====================================================================\n"; 3152 } 3153 3154 if ($failed_test_summary && !getenv('NO_PHPTEST_SUMMARY')) { 3155 $summary .= $failed_test_summary; 3156 } 3157 3158 return $summary; 3159} 3160 3161function show_start(int $start_timestamp): void 3162{ 3163 echo "TIME START " . date('Y-m-d H:i:s', $start_timestamp) . "\n=====================================================================\n"; 3164} 3165 3166function show_end(int $start_timestamp, int|float $start_time, int|float $end_time): void 3167{ 3168 echo "=====================================================================\nTIME END " . date('Y-m-d H:i:s', $start_timestamp + (int)(($end_time - $start_time)/1e9)) . "\n"; 3169} 3170 3171function show_summary(): void 3172{ 3173 echo get_summary(true); 3174} 3175 3176function show_redirect_start(string $tests, string $tested, string $tested_file): void 3177{ 3178 global $SHOW_ONLY_GROUPS, $show_progress; 3179 3180 if (!$SHOW_ONLY_GROUPS || in_array('REDIRECT', $SHOW_ONLY_GROUPS)) { 3181 echo "REDIRECT $tests ($tested [$tested_file]) begin\n"; 3182 } elseif ($show_progress) { 3183 clear_show_test(); 3184 } 3185} 3186 3187function show_redirect_ends(string $tests, string $tested, string $tested_file): void 3188{ 3189 global $SHOW_ONLY_GROUPS, $show_progress; 3190 3191 if (!$SHOW_ONLY_GROUPS || in_array('REDIRECT', $SHOW_ONLY_GROUPS)) { 3192 echo "REDIRECT $tests ($tested [$tested_file]) done\n"; 3193 } elseif ($show_progress) { 3194 clear_show_test(); 3195 } 3196} 3197 3198function show_test(int $test_idx, string $shortname): void 3199{ 3200 global $test_cnt; 3201 global $line_length; 3202 3203 $str = "TEST $test_idx/$test_cnt [$shortname]\r"; 3204 $line_length = strlen($str); 3205 echo $str; 3206 flush(); 3207} 3208 3209function clear_show_test(): void 3210{ 3211 global $line_length; 3212 // Parallel testing 3213 global $workerID; 3214 3215 if (!$workerID && isset($line_length)) { 3216 // Write over the last line to avoid random trailing chars on next echo 3217 echo str_repeat(" ", $line_length), "\r"; 3218 } 3219} 3220 3221function parse_conflicts(string $text): array 3222{ 3223 // Strip comments 3224 $text = preg_replace('/#.*/', '', $text); 3225 return array_map('trim', explode("\n", trim($text))); 3226} 3227 3228function show_result( 3229 string $result, 3230 string $tested, 3231 string $tested_file, 3232 string $extra = '' 3233): void { 3234 global $SHOW_ONLY_GROUPS, $colorize, $show_progress; 3235 3236 if (!$SHOW_ONLY_GROUPS || in_array($result, $SHOW_ONLY_GROUPS)) { 3237 if ($colorize) { 3238 /* Use ANSI escape codes for coloring test result */ 3239 switch ( $result ) { 3240 case 'PASS': // Light Green 3241 $color = "\e[1;32m{$result}\e[0m"; break; 3242 case 'FAIL': 3243 case 'BORK': 3244 case 'LEAK': 3245 case 'LEAK&FAIL': 3246 // Light Red 3247 $color = "\e[1;31m{$result}\e[0m"; break; 3248 default: // Yellow 3249 $color = "\e[1;33m{$result}\e[0m"; break; 3250 } 3251 3252 echo "$color $tested [$tested_file] $extra\n"; 3253 } else { 3254 echo "$result $tested [$tested_file] $extra\n"; 3255 } 3256 } elseif ($show_progress) { 3257 clear_show_test(); 3258 } 3259} 3260 3261class BorkageException extends Exception 3262{ 3263} 3264 3265class JUnit 3266{ 3267 private bool $enabled = true; 3268 private $fp = null; 3269 private array $suites = []; 3270 private array $rootSuite = self::EMPTY_SUITE + ['name' => 'php']; 3271 3272 private const EMPTY_SUITE = [ 3273 'test_total' => 0, 3274 'test_pass' => 0, 3275 'test_fail' => 0, 3276 'test_error' => 0, 3277 'test_skip' => 0, 3278 'test_warn' => 0, 3279 'files' => [], 3280 'execution_time' => 0, 3281 ]; 3282 3283 /** 3284 * @throws Exception 3285 */ 3286 public function __construct(array $env, int $workerID) 3287 { 3288 // Check whether a junit log is wanted. 3289 $fileName = $env['TEST_PHP_JUNIT'] ?? null; 3290 if (empty($fileName)) { 3291 $this->enabled = false; 3292 return; 3293 } 3294 if (!$workerID && !$this->fp = fopen($fileName, 'w')) { 3295 throw new Exception("Failed to open $fileName for writing."); 3296 } 3297 } 3298 3299 public function isEnabled(): bool 3300 { 3301 return $this->enabled; 3302 } 3303 3304 public function clear(): void 3305 { 3306 $this->rootSuite = self::EMPTY_SUITE + ['name' => 'php']; 3307 $this->suites = []; 3308 } 3309 3310 public function saveXML(): void 3311 { 3312 if (!$this->enabled) { 3313 return; 3314 } 3315 3316 $xml = '<' . '?' . 'xml version="1.0" encoding="UTF-8"' . '?' . '>' . PHP_EOL; 3317 $xml .= sprintf( 3318 '<testsuites name="%s" tests="%s" failures="%d" errors="%d" skip="%d" time="%s">' . PHP_EOL, 3319 $this->rootSuite['name'], 3320 $this->rootSuite['test_total'], 3321 $this->rootSuite['test_fail'], 3322 $this->rootSuite['test_error'], 3323 $this->rootSuite['test_skip'], 3324 $this->rootSuite['execution_time'] 3325 ); 3326 $xml .= $this->getSuitesXML(); 3327 $xml .= '</testsuites>'; 3328 fwrite($this->fp, $xml); 3329 } 3330 3331 private function getSuitesXML(): string 3332 { 3333 $result = ''; 3334 3335 foreach ($this->suites as $suite_name => $suite) { 3336 $result .= sprintf( 3337 '<testsuite name="%s" tests="%s" failures="%d" errors="%d" skip="%d" time="%s">' . PHP_EOL, 3338 $suite['name'], 3339 $suite['test_total'], 3340 $suite['test_fail'], 3341 $suite['test_error'], 3342 $suite['test_skip'], 3343 $suite['execution_time'] 3344 ); 3345 3346 if (!empty($suite_name)) { 3347 foreach ($suite['files'] as $file) { 3348 $result .= $this->rootSuite['files'][$file]['xml']; 3349 } 3350 } 3351 3352 $result .= '</testsuite>' . PHP_EOL; 3353 } 3354 3355 return $result; 3356 } 3357 3358 public function markTestAs( 3359 $type, 3360 string $file_name, 3361 string $test_name, 3362 ?int $time = null, 3363 string $message = '', 3364 string $details = '' 3365 ): void { 3366 if (!$this->enabled) { 3367 return; 3368 } 3369 3370 $suite = $this->getSuiteName($file_name); 3371 3372 $this->record($suite, 'test_total'); 3373 3374 $time = $time ?? $this->getTimer($file_name); 3375 $this->record($suite, 'execution_time', $time); 3376 3377 $escaped_details = htmlspecialchars($details, ENT_QUOTES, 'UTF-8'); 3378 $escaped_details = preg_replace_callback('/[\0-\x08\x0B\x0C\x0E-\x1F]/', function ($c) { 3379 return sprintf('[[0x%02x]]', ord($c[0])); 3380 }, $escaped_details); 3381 $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8'); 3382 3383 $escaped_test_name = htmlspecialchars($file_name . ' (' . $test_name . ')', ENT_QUOTES); 3384 $this->rootSuite['files'][$file_name]['xml'] = "<testcase name='$escaped_test_name' time='$time'>\n"; 3385 3386 if (is_array($type)) { 3387 $output_type = $type[0] . 'ED'; 3388 $temp = array_intersect(['XFAIL', 'XLEAK', 'FAIL', 'WARN'], $type); 3389 $type = reset($temp); 3390 } else { 3391 $output_type = $type . 'ED'; 3392 } 3393 3394 if ('PASS' == $type || 'XFAIL' == $type || 'XLEAK' == $type) { 3395 $this->record($suite, 'test_pass'); 3396 } elseif ('BORK' == $type) { 3397 $this->record($suite, 'test_error'); 3398 $this->rootSuite['files'][$file_name]['xml'] .= "<error type='$output_type' message='$escaped_message'/>\n"; 3399 } elseif ('SKIP' == $type) { 3400 $this->record($suite, 'test_skip'); 3401 $this->rootSuite['files'][$file_name]['xml'] .= "<skipped>$escaped_message</skipped>\n"; 3402 } elseif ('WARN' == $type) { 3403 $this->record($suite, 'test_warn'); 3404 $this->rootSuite['files'][$file_name]['xml'] .= "<warning>$escaped_message</warning>\n"; 3405 } elseif ('FAIL' == $type) { 3406 $this->record($suite, 'test_fail'); 3407 $this->rootSuite['files'][$file_name]['xml'] .= "<failure type='$output_type' message='$escaped_message'>$escaped_details</failure>\n"; 3408 } else { 3409 $this->record($suite, 'test_error'); 3410 $this->rootSuite['files'][$file_name]['xml'] .= "<error type='$output_type' message='$escaped_message'>$escaped_details</error>\n"; 3411 } 3412 3413 $this->rootSuite['files'][$file_name]['xml'] .= "</testcase>\n"; 3414 } 3415 3416 private function record(string $suite, string $param, $value = 1): void 3417 { 3418 $this->rootSuite[$param] += $value; 3419 $this->suites[$suite][$param] += $value; 3420 } 3421 3422 private function getTimer(string $file_name) 3423 { 3424 if (!$this->enabled) { 3425 return 0; 3426 } 3427 3428 if (isset($this->rootSuite['files'][$file_name]['total'])) { 3429 return number_format($this->rootSuite['files'][$file_name]['total'], 4); 3430 } 3431 3432 return 0; 3433 } 3434 3435 public function startTimer(string $file_name): void 3436 { 3437 if (!$this->enabled) { 3438 return; 3439 } 3440 3441 if (!isset($this->rootSuite['files'][$file_name]['start'])) { 3442 $this->rootSuite['files'][$file_name]['start'] = microtime(true); 3443 3444 $suite = $this->getSuiteName($file_name); 3445 $this->initSuite($suite); 3446 $this->suites[$suite]['files'][$file_name] = $file_name; 3447 } 3448 } 3449 3450 public function getSuiteName(string $file_name): string 3451 { 3452 return $this->pathToClassName(dirname($file_name)); 3453 } 3454 3455 private function pathToClassName(string $file_name): string 3456 { 3457 if (!$this->enabled) { 3458 return ''; 3459 } 3460 3461 $ret = $this->rootSuite['name']; 3462 $_tmp = []; 3463 3464 // lookup whether we're in the PHP source checkout 3465 $max = 5; 3466 if (is_file($file_name)) { 3467 $dir = dirname(realpath($file_name)); 3468 } else { 3469 $dir = realpath($file_name); 3470 } 3471 do { 3472 array_unshift($_tmp, basename($dir)); 3473 $chk = $dir . DIRECTORY_SEPARATOR . "main" . DIRECTORY_SEPARATOR . "php_version.h"; 3474 $dir = dirname($dir); 3475 } while (!file_exists($chk) && --$max > 0); 3476 if (file_exists($chk)) { 3477 if ($max) { 3478 array_shift($_tmp); 3479 } 3480 foreach ($_tmp as $p) { 3481 $ret .= "." . preg_replace(",[^a-z0-9]+,i", ".", $p); 3482 } 3483 return $ret; 3484 } 3485 3486 return $this->rootSuite['name'] . '.' . str_replace([DIRECTORY_SEPARATOR, '-'], '.', $file_name); 3487 } 3488 3489 public function initSuite(string $suite_name): void 3490 { 3491 if (!$this->enabled) { 3492 return; 3493 } 3494 3495 if (!empty($this->suites[$suite_name])) { 3496 return; 3497 } 3498 3499 $this->suites[$suite_name] = self::EMPTY_SUITE + ['name' => $suite_name]; 3500 } 3501 3502 /** 3503 * @throws Exception 3504 */ 3505 public function stopTimer(string $file_name): void 3506 { 3507 if (!$this->enabled) { 3508 return; 3509 } 3510 3511 if (!isset($this->rootSuite['files'][$file_name]['start'])) { 3512 throw new Exception("Timer for $file_name was not started!"); 3513 } 3514 3515 if (!isset($this->rootSuite['files'][$file_name]['total'])) { 3516 $this->rootSuite['files'][$file_name]['total'] = 0; 3517 } 3518 3519 $start = $this->rootSuite['files'][$file_name]['start']; 3520 $this->rootSuite['files'][$file_name]['total'] += microtime(true) - $start; 3521 unset($this->rootSuite['files'][$file_name]['start']); 3522 } 3523 3524 public function mergeResults(?JUnit $other): void 3525 { 3526 if (!$this->enabled || !$other) { 3527 return; 3528 } 3529 3530 $this->mergeSuites($this->rootSuite, $other->rootSuite); 3531 foreach ($other->suites as $name => $suite) { 3532 if (!isset($this->suites[$name])) { 3533 $this->suites[$name] = $suite; 3534 continue; 3535 } 3536 3537 $this->mergeSuites($this->suites[$name], $suite); 3538 } 3539 } 3540 3541 private function mergeSuites(array &$dest, array $source): void 3542 { 3543 $dest['test_total'] += $source['test_total']; 3544 $dest['test_pass'] += $source['test_pass']; 3545 $dest['test_fail'] += $source['test_fail']; 3546 $dest['test_error'] += $source['test_error']; 3547 $dest['test_skip'] += $source['test_skip']; 3548 $dest['test_warn'] += $source['test_warn']; 3549 $dest['execution_time'] += $source['execution_time']; 3550 $dest['files'] += $source['files']; 3551 } 3552} 3553 3554class SkipCache 3555{ 3556 private bool $enable; 3557 private bool $keepFile; 3558 3559 private array $skips = []; 3560 private array $extensions = []; 3561 3562 private int $hits = 0; 3563 private int $misses = 0; 3564 private int $extHits = 0; 3565 private int $extMisses = 0; 3566 3567 public function __construct(bool $enable, bool $keepFile) 3568 { 3569 $this->enable = $enable; 3570 $this->keepFile = $keepFile; 3571 } 3572 3573 public function checkSkip(string $php, string $code, string $checkFile, string $tempFile, array $env): string 3574 { 3575 // Extension tests frequently use something like <?php require 'skipif.inc'; 3576 // for skip checks. This forces us to cache per directory to avoid pollution. 3577 $dir = dirname($checkFile); 3578 $key = "$php => $dir"; 3579 3580 if (isset($this->skips[$key][$code])) { 3581 $this->hits++; 3582 if ($this->keepFile) { 3583 save_text($checkFile, $code, $tempFile); 3584 } 3585 return $this->skips[$key][$code]; 3586 } 3587 3588 save_text($checkFile, $code, $tempFile); 3589 $result = trim(system_with_timeout("$php \"$checkFile\"", $env)); 3590 if (strpos($result, 'nocache') === 0) { 3591 $result = ''; 3592 } else if ($this->enable) { 3593 $this->skips[$key][$code] = $result; 3594 } 3595 $this->misses++; 3596 3597 if (!$this->keepFile) { 3598 @unlink($checkFile); 3599 } 3600 3601 return $result; 3602 } 3603 3604 public function getExtensions(string $php): array 3605 { 3606 if (isset($this->extensions[$php])) { 3607 $this->extHits++; 3608 return $this->extensions[$php]; 3609 } 3610 3611 $extDir = shell_exec("$php -d display_errors=0 -r \"echo ini_get('extension_dir');\""); 3612 $extensionsNames = explode(",", shell_exec("$php -d display_errors=0 -r \"echo implode(',', get_loaded_extensions());\"")); 3613 $extensions = remap_loaded_extensions_names($extensionsNames); 3614 3615 $result = [$extDir, $extensions]; 3616 $this->extensions[$php] = $result; 3617 $this->extMisses++; 3618 3619 return $result; 3620 } 3621} 3622 3623class RuntestsValgrind 3624{ 3625 protected string $header; 3626 protected bool $version_3_8_0; 3627 protected string $tool; 3628 3629 public function getHeader(): string 3630 { 3631 return $this->header; 3632 } 3633 3634 public function __construct(array $environment, string $tool = 'memcheck') 3635 { 3636 $this->tool = $tool; 3637 $header = system_with_timeout("valgrind --tool={$this->tool} --version", $environment); 3638 if (!$header) { 3639 error("Valgrind returned no version info for {$this->tool}, cannot proceed.\n". 3640 "Please check if Valgrind is installed and the tool is named correctly."); 3641 } 3642 $count = 0; 3643 $version = preg_replace("/valgrind-(\d+)\.(\d+)\.(\d+)([.\w_-]+)?(\s+)/", '$1.$2.$3', $header, 1, $count); 3644 if ($count != 1) { 3645 error("Valgrind returned invalid version info (\"{$header}\") for {$this->tool}, cannot proceed."); 3646 } 3647 $this->header = sprintf("%s (%s)", trim($header), $this->tool); 3648 $this->version_3_8_0 = version_compare($version, '3.8.0', '>='); 3649 } 3650 3651 public function wrapCommand(string $cmd, string $memcheck_filename, bool $check_all): string 3652 { 3653 $vcmd = "valgrind -q --tool={$this->tool} --trace-children=yes"; 3654 if ($check_all) { 3655 $vcmd .= ' --smc-check=all'; 3656 } 3657 3658 /* --vex-iropt-register-updates=allregs-at-mem-access is necessary for phpdbg watchpoint tests */ 3659 if ($this->version_3_8_0) { 3660 return "$vcmd --vex-iropt-register-updates=allregs-at-mem-access --log-file=$memcheck_filename $cmd"; 3661 } 3662 return "$vcmd --vex-iropt-precise-memory-exns=yes --log-file=$memcheck_filename $cmd"; 3663 } 3664} 3665 3666class TestFile 3667{ 3668 private string $fileName; 3669 3670 private array $sections = ['TEST' => '']; 3671 3672 private const ALLOWED_SECTIONS = [ 3673 'EXPECT', 'EXPECTF', 'EXPECTREGEX', 'EXPECTREGEX_EXTERNAL', 'EXPECT_EXTERNAL', 'EXPECTF_EXTERNAL', 'EXPECTHEADERS', 3674 'POST', 'POST_RAW', 'GZIP_POST', 'DEFLATE_POST', 'PUT', 'GET', 'COOKIE', 'ARGS', 3675 'FILE', 'FILEEOF', 'FILE_EXTERNAL', 'REDIRECTTEST', 3676 'CAPTURE_STDIO', 'STDIN', 'CGI', 'PHPDBG', 3677 'INI', 'ENV', 'EXTENSIONS', 3678 'SKIPIF', 'XFAIL', 'XLEAK', 'CLEAN', 3679 'CREDITS', 'DESCRIPTION', 'CONFLICTS', 'WHITESPACE_SENSITIVE', 3680 'FLAKY', 3681 ]; 3682 3683 /** 3684 * @throws BorkageException 3685 */ 3686 public function __construct(string $fileName, bool $inRedirect) 3687 { 3688 $this->fileName = $fileName; 3689 3690 $this->readFile(); 3691 $this->validateAndProcess($inRedirect); 3692 } 3693 3694 public function hasSection(string $name): bool 3695 { 3696 return isset($this->sections[$name]); 3697 } 3698 3699 public function hasAnySections(string ...$names): bool 3700 { 3701 foreach ($names as $section) { 3702 if (isset($this->sections[$section])) { 3703 return true; 3704 } 3705 } 3706 3707 return false; 3708 } 3709 3710 public function sectionNotEmpty(string $name): bool 3711 { 3712 return !empty($this->sections[$name]); 3713 } 3714 3715 /** 3716 * @throws Exception 3717 */ 3718 public function getSection(string $name): string 3719 { 3720 if (!isset($this->sections[$name])) { 3721 throw new Exception("Section $name not found"); 3722 } 3723 return $this->sections[$name]; 3724 } 3725 3726 public function getName(): string 3727 { 3728 return trim($this->getSection('TEST')); 3729 } 3730 3731 public function isCGI(): bool 3732 { 3733 return $this->hasSection('CGI') 3734 || $this->sectionNotEmpty('GET') 3735 || $this->sectionNotEmpty('POST') 3736 || $this->sectionNotEmpty('GZIP_POST') 3737 || $this->sectionNotEmpty('DEFLATE_POST') 3738 || $this->sectionNotEmpty('POST_RAW') 3739 || $this->sectionNotEmpty('PUT') 3740 || $this->sectionNotEmpty('COOKIE') 3741 || $this->sectionNotEmpty('EXPECTHEADERS'); 3742 } 3743 3744 /** 3745 * TODO Refactor to make it not needed 3746 */ 3747 public function setSection(string $name, string $value): void 3748 { 3749 $this->sections[$name] = $value; 3750 } 3751 3752 /** 3753 * Load the sections of the test file 3754 * @throws BorkageException 3755 */ 3756 private function readFile(): void 3757 { 3758 $fp = fopen($this->fileName, "rb") or error("Cannot open test file: {$this->fileName}"); 3759 3760 if (!feof($fp)) { 3761 $line = fgets($fp); 3762 3763 if ($line === false) { 3764 throw new BorkageException("cannot read test"); 3765 } 3766 } else { 3767 throw new BorkageException("empty test [{$this->fileName}]"); 3768 } 3769 if (strncmp('--TEST--', $line, 8)) { 3770 throw new BorkageException("tests must start with --TEST-- [{$this->fileName}]"); 3771 } 3772 3773 $section = 'TEST'; 3774 $secfile = false; 3775 $secdone = false; 3776 3777 while (!feof($fp)) { 3778 $line = fgets($fp); 3779 3780 if ($line === false) { 3781 break; 3782 } 3783 3784 // Match the beginning of a section. 3785 if (preg_match('/^--([_A-Z]+)--/', $line, $r)) { 3786 $section = $r[1]; 3787 3788 if (isset($this->sections[$section]) && $this->sections[$section]) { 3789 throw new BorkageException("duplicated $section section"); 3790 } 3791 3792 // check for unknown sections 3793 if (!in_array($section, self::ALLOWED_SECTIONS)) { 3794 throw new BorkageException('Unknown section "' . $section . '"'); 3795 } 3796 3797 $this->sections[$section] = ''; 3798 $secfile = $section == 'FILE' || $section == 'FILEEOF' || $section == 'FILE_EXTERNAL'; 3799 $secdone = false; 3800 continue; 3801 } 3802 3803 // Add to the section text. 3804 if (!$secdone) { 3805 $this->sections[$section] .= $line; 3806 } 3807 3808 // End of actual test? 3809 if ($secfile && preg_match('/^===DONE===\s*$/', $line)) { 3810 $secdone = true; 3811 } 3812 } 3813 3814 fclose($fp); 3815 } 3816 3817 /** 3818 * @throws BorkageException 3819 */ 3820 private function validateAndProcess(bool $inRedirect): void 3821 { 3822 // the redirect section allows a set of tests to be reused outside of 3823 // a given test dir 3824 if ($this->hasSection('REDIRECTTEST')) { 3825 if ($inRedirect) { 3826 throw new BorkageException("Can't redirect a test from within a redirected test"); 3827 } 3828 return; 3829 } 3830 if (!$this->hasSection('PHPDBG') && $this->hasSection('FILE') + $this->hasSection('FILEEOF') + $this->hasSection('FILE_EXTERNAL') != 1) { 3831 throw new BorkageException("missing section --FILE--"); 3832 } 3833 3834 if ($this->hasSection('FILEEOF')) { 3835 $this->sections['FILE'] = preg_replace("/[\r\n]+$/", '', $this->sections['FILEEOF']); 3836 unset($this->sections['FILEEOF']); 3837 } 3838 3839 foreach (['FILE', 'EXPECT', 'EXPECTF', 'EXPECTREGEX'] as $prefix) { 3840 // For grepping: FILE_EXTERNAL, EXPECT_EXTERNAL, EXPECTF_EXTERNAL, EXPECTREGEX_EXTERNAL 3841 $key = $prefix . '_EXTERNAL'; 3842 3843 if ($this->hasSection($key)) { 3844 // don't allow tests to retrieve files from anywhere but this subdirectory 3845 $dir = dirname($this->fileName); 3846 $fileName = $dir . '/' . trim(str_replace('..', '', $this->getSection($key))); 3847 3848 if (file_exists($fileName)) { 3849 $this->sections[$prefix] = file_get_contents($fileName); 3850 } else { 3851 throw new BorkageException("could not load --" . $key . "-- " . $dir . '/' . trim($fileName)); 3852 } 3853 } 3854 } 3855 3856 if (($this->hasSection('EXPECT') + $this->hasSection('EXPECTF') + $this->hasSection('EXPECTREGEX')) != 1) { 3857 throw new BorkageException("missing section --EXPECT--, --EXPECTF-- or --EXPECTREGEX--"); 3858 } 3859 3860 if ($this->hasSection('PHPDBG') && !$this->hasSection('STDIN')) { 3861 $this->sections['STDIN'] = $this->sections['PHPDBG'] . "\n"; 3862 } 3863 } 3864} 3865 3866function init_output_buffers(): void 3867{ 3868 // Delete as much output buffers as possible. 3869 while (@ob_end_clean()) { 3870 } 3871 3872 if (ob_get_level()) { 3873 echo "Not all buffers were deleted.\n"; 3874 } 3875} 3876 3877function check_proc_open_function_exists(): void 3878{ 3879 if (!function_exists('proc_open')) { 3880 echo <<<NO_PROC_OPEN_ERROR 3881 3882+-----------------------------------------------------------+ 3883| ! ERROR ! | 3884| The test-suite requires that proc_open() is available. | 3885| Please check if you disabled it in php.ini. | 3886+-----------------------------------------------------------+ 3887 3888NO_PROC_OPEN_ERROR; 3889 exit(1); 3890 } 3891} 3892 3893function bless_failed_tests(array $failedTests): void 3894{ 3895 if (empty($failedTests)) { 3896 return; 3897 } 3898 $args = [ 3899 PHP_BINARY, 3900 __DIR__ . '/scripts/dev/bless_tests.php', 3901 ]; 3902 foreach ($failedTests as $test) { 3903 $args[] = $test['name']; 3904 } 3905 proc_open($args, [], $pipes); 3906} 3907 3908/* 3909 * BSD 3-Clause License 3910 * 3911 * Copyright (c) 2002-2023, Sebastian Bergmann 3912 * All rights reserved. 3913 * 3914 * This file is part of sebastian/diff. 3915 * https://github.com/sebastianbergmann/diff 3916 */ 3917 3918final class Differ 3919{ 3920 public const OLD = 0; 3921 public const ADDED = 1; 3922 public const REMOVED = 2; 3923 private DiffOutputBuilder $outputBuilder; 3924 private $isEqual; 3925 3926 public function __construct(callable $isEqual) 3927 { 3928 $this->outputBuilder = new DiffOutputBuilder; 3929 $this->isEqual = $isEqual; 3930 } 3931 3932 public function diff(array $from, array $to): string 3933 { 3934 $diff = $this->diffToArray($from, $to); 3935 3936 return $this->outputBuilder->getDiff($diff); 3937 } 3938 3939 public function diffToArray(array $from, array $to): array 3940 { 3941 $fromLine = 1; 3942 $toLine = 1; 3943 3944 [$from, $to, $start, $end] = $this->getArrayDiffParted($from, $to); 3945 3946 $common = $this->calculateCommonSubsequence(array_values($from), array_values($to)); 3947 $diff = []; 3948 3949 foreach ($start as $token) { 3950 $diff[] = [$token, self::OLD]; 3951 $fromLine++; 3952 $toLine++; 3953 } 3954 3955 reset($from); 3956 reset($to); 3957 3958 foreach ($common as $token) { 3959 while (!empty($from) && !($this->isEqual)(reset($from), $token)) { 3960 $diff[] = [array_shift($from), self::REMOVED, $fromLine++]; 3961 } 3962 3963 while (!empty($to) && !($this->isEqual)($token, reset($to))) { 3964 $diff[] = [array_shift($to), self::ADDED, $toLine++]; 3965 } 3966 3967 $diff[] = [$token, self::OLD]; 3968 $fromLine++; 3969 $toLine++; 3970 3971 array_shift($from); 3972 array_shift($to); 3973 } 3974 3975 while (($token = array_shift($from)) !== null) { 3976 $diff[] = [$token, self::REMOVED, $fromLine++]; 3977 } 3978 3979 while (($token = array_shift($to)) !== null) { 3980 $diff[] = [$token, self::ADDED, $toLine++]; 3981 } 3982 3983 foreach ($end as $token) { 3984 $diff[] = [$token, self::OLD]; 3985 } 3986 3987 return $diff; 3988 } 3989 3990 private function getArrayDiffParted(array &$from, array &$to): array 3991 { 3992 $start = []; 3993 $end = []; 3994 3995 reset($to); 3996 3997 foreach ($from as $k => $v) { 3998 $toK = key($to); 3999 4000 if (($this->isEqual)($toK, $k) && ($this->isEqual)($v, $to[$k])) { 4001 $start[$k] = $v; 4002 4003 unset($from[$k], $to[$k]); 4004 } else { 4005 break; 4006 } 4007 } 4008 4009 end($from); 4010 end($to); 4011 4012 do { 4013 $fromK = key($from); 4014 $toK = key($to); 4015 4016 if (null === $fromK || null === $toK || !($this->isEqual)(current($from), current($to))) { 4017 break; 4018 } 4019 4020 prev($from); 4021 prev($to); 4022 4023 $end = [$fromK => $from[$fromK]] + $end; 4024 unset($from[$fromK], $to[$toK]); 4025 } while (true); 4026 4027 return [$from, $to, $start, $end]; 4028 } 4029 4030 public function calculateCommonSubsequence(array $from, array $to): array 4031 { 4032 $cFrom = count($from); 4033 $cTo = count($to); 4034 4035 if ($cFrom === 0) { 4036 return []; 4037 } 4038 4039 if ($cFrom === 1) { 4040 foreach ($to as $toV) { 4041 if (($this->isEqual)($from[0], $toV)) { 4042 return [$toV]; 4043 } 4044 } 4045 4046 return []; 4047 } 4048 4049 $i = (int) ($cFrom / 2); 4050 $fromStart = array_slice($from, 0, $i); 4051 $fromEnd = array_slice($from, $i); 4052 $llB = $this->commonSubsequenceLength($fromStart, $to); 4053 $llE = $this->commonSubsequenceLength(array_reverse($fromEnd), array_reverse($to)); 4054 $jMax = 0; 4055 $max = 0; 4056 4057 for ($j = 0; $j <= $cTo; $j++) { 4058 $m = $llB[$j] + $llE[$cTo - $j]; 4059 4060 if ($m >= $max) { 4061 $max = $m; 4062 $jMax = $j; 4063 } 4064 } 4065 4066 $toStart = array_slice($to, 0, $jMax); 4067 $toEnd = array_slice($to, $jMax); 4068 4069 return array_merge( 4070 $this->calculateCommonSubsequence($fromStart, $toStart), 4071 $this->calculateCommonSubsequence($fromEnd, $toEnd) 4072 ); 4073 } 4074 4075 private function commonSubsequenceLength(array $from, array $to): array 4076 { 4077 $current = array_fill(0, count($to) + 1, 0); 4078 $cFrom = count($from); 4079 $cTo = count($to); 4080 4081 for ($i = 0; $i < $cFrom; $i++) { 4082 $prev = $current; 4083 4084 for ($j = 0; $j < $cTo; $j++) { 4085 if (($this->isEqual)($from[$i], $to[$j])) { 4086 $current[$j + 1] = $prev[$j] + 1; 4087 } else { 4088 $current[$j + 1] = max($current[$j], $prev[$j + 1]); 4089 } 4090 } 4091 } 4092 4093 return $current; 4094 } 4095} 4096 4097class DiffOutputBuilder 4098{ 4099 public function getDiff(array $diffs): string 4100 { 4101 global $context_line_count; 4102 $i = 0; 4103 $number_len = max(3, strlen((string)count($diffs))); 4104 $line_number_spec = '%0' . $number_len . 'd'; 4105 $buffer = fopen('php://memory', 'r+b'); 4106 while ($i < count($diffs)) { 4107 // Find next difference 4108 $next = $i; 4109 while ($next < count($diffs)) { 4110 if ($diffs[$next][1] !== Differ::OLD) { 4111 break; 4112 } 4113 $next++; 4114 } 4115 // Found no more differentiating rows, we're done 4116 if ($next === count($diffs)) { 4117 if (($i - 1) < count($diffs)) { 4118 fwrite($buffer, "--\n"); 4119 } 4120 break; 4121 } 4122 // Print separator if necessary 4123 if ($i < ($next - $context_line_count)) { 4124 fwrite($buffer, "--\n"); 4125 $i = $next - $context_line_count; 4126 } 4127 // Print leading context 4128 while ($i < $next) { 4129 fwrite($buffer, str_repeat(' ', $number_len + 2)); 4130 fwrite($buffer, $diffs[$i][0]); 4131 fwrite($buffer, "\n"); 4132 $i++; 4133 } 4134 // Print differences 4135 while ($i < count($diffs) && $diffs[$i][1] !== Differ::OLD) { 4136 fwrite($buffer, sprintf($line_number_spec, $diffs[$i][2])); 4137 switch ($diffs[$i][1]) { 4138 case Differ::ADDED: 4139 fwrite($buffer, '+ '); 4140 break; 4141 case Differ::REMOVED: 4142 fwrite($buffer, '- '); 4143 break; 4144 } 4145 fwrite($buffer, $diffs[$i][0]); 4146 fwrite($buffer, "\n"); 4147 $i++; 4148 } 4149 // Print trailing context 4150 $afterContext = min($i + $context_line_count, count($diffs)); 4151 while ($i < $afterContext && $diffs[$i][1] === Differ::OLD) { 4152 fwrite($buffer, str_repeat(' ', $number_len + 2)); 4153 fwrite($buffer, $diffs[$i][0]); 4154 fwrite($buffer, "\n"); 4155 $i++; 4156 } 4157 } 4158 4159 $diff = stream_get_contents($buffer, -1, 0); 4160 fclose($buffer); 4161 4162 return $diff; 4163 } 4164} 4165 4166main(); 4167