From 2e5a74a7cd72893b212e047d8e5b658ca852eebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=B6ller?= Date: Wed, 6 Dec 2023 14:05:33 +0100 Subject: [PATCH] Enhancement: Copy run-tests.php from php/php-src --- tests/run-tests.php | 2748 +++++++++++++++++++++++++------------------ 1 file changed, 1600 insertions(+), 1148 deletions(-) mode change 100644 => 100755 tests/run-tests.php diff --git a/tests/run-tests.php b/tests/run-tests.php old mode 100644 new mode 100755 index da18b8b095..7ee6370613 --- a/tests/run-tests.php +++ b/tests/run-tests.php @@ -23,12 +23,16 @@ +----------------------------------------------------------------------+ */ -/* $Id$ */ +/* Temporary variables while this file is being refactored. */ +/** @var ?JUnit $junit */ +$junit = null; + +/* End temporary variables. */ /* Let there be no top-level code beyond this point: * Only functions and classes, thanks! * - * Minimum required PHP version: 7.1.0 + * Minimum required PHP version: 7.4.0 */ function show_usage(): void @@ -121,6 +125,14 @@ function show_usage(): void --color --no-color Do/Don't colorize the result type in the test result. + --progress + --no-progress Do/Don't show the current progress. + + --repeat [n] + Run the tests multiple times in the same process and check the + output of the last execution (CLI SAPI only). + + --bless Bless failed tests using scripts/dev/bless_tests.php. HELP; } @@ -141,17 +153,22 @@ function main(): void $cfgfiles, $cfgtypes, $conf_passed, $end_time, $environment, $exts_skipped, $exts_tested, $exts_to_test, $failed_tests_file, $ignored_by_ext, $ini_overwrites, $is_switch, $colorize, - $just_save_results, $log_format, $matches, $no_clean, $no_file_cache, - $optionals, $output_file, $pass_option_n, $pass_options, + $log_format, $matches, $no_clean, $no_file_cache, + $optionals, $pass_option_n, $pass_options, $pattern_match, $php, $php_cgi, $phpdbg, $preload, $redir_tests, $repeat, $result_tests_file, $slow_min_ms, $start_time, $switch, $temp_source, $temp_target, $test_cnt, $test_dirs, $test_files, $test_idx, $test_list, $test_results, $testfile, - $user_tests, $valgrind, $sum_results, $shuffle, $file_cache; + $user_tests, $valgrind, $sum_results, $shuffle, $file_cache, $num_repeats, + $bless, $show_progress; // Parallel testing global $workers, $workerID; global $context_line_count; + // Temporary for the duration of refactoring + /** @var JUnit $junit */ + global $junit; + define('IS_WINDOWS', substr(PHP_OS, 0, 3) == "WIN"); $workerID = 0; @@ -212,13 +229,13 @@ function main(): void // fail to reattach to the OpCache because it will be using the // wrong path. die("TEMP environment is NOT set"); - } else { - if (count($environment) == 1) { - // Not having other environment variables, only having TEMP, is - // probably ok, but strange and may make a difference in the - // test pass rate, so warn the user. - echo "WARNING: Only 1 environment variable will be available to tests(TEMP environment variable)" . PHP_EOL; - } + } + + if (count($environment) == 1) { + // Not having other environment variables, only having TEMP, is + // probably ok, but strange and may make a difference in the + // test pass rate, so warn the user. + echo "WARNING: Only 1 environment variable will be available to tests(TEMP environment variable)" , PHP_EOL; } } @@ -230,65 +247,6 @@ function main(): void $php_cgi = null; $phpdbg = null; - if (getenv('TEST_PHP_EXECUTABLE')) { - $php = getenv('TEST_PHP_EXECUTABLE'); - - if ($php == 'auto') { - $php = TEST_PHP_SRCDIR . '/sapi/cli/php'; - putenv("TEST_PHP_EXECUTABLE=$php"); - - if (!getenv('TEST_PHP_CGI_EXECUTABLE')) { - $php_cgi = TEST_PHP_SRCDIR . '/sapi/cgi/php-cgi'; - - if (file_exists($php_cgi)) { - putenv("TEST_PHP_CGI_EXECUTABLE=$php_cgi"); - } else { - $php_cgi = null; - } - } - } - $environment['TEST_PHP_EXECUTABLE'] = $php; - } - - if (getenv('TEST_PHP_CGI_EXECUTABLE')) { - $php_cgi = getenv('TEST_PHP_CGI_EXECUTABLE'); - - if ($php_cgi == 'auto') { - $php_cgi = TEST_PHP_SRCDIR . '/sapi/cgi/php-cgi'; - putenv("TEST_PHP_CGI_EXECUTABLE=$php_cgi"); - } - - $environment['TEST_PHP_CGI_EXECUTABLE'] = $php_cgi; - } - - if (!getenv('TEST_PHPDBG_EXECUTABLE')) { - if (IS_WINDOWS && file_exists(dirname($php) . "/phpdbg.exe")) { - $phpdbg = realpath(dirname($php) . "/phpdbg.exe"); - } elseif (file_exists(dirname($php) . "/../../sapi/phpdbg/phpdbg")) { - $phpdbg = realpath(dirname($php) . "/../../sapi/phpdbg/phpdbg"); - } elseif (file_exists("./sapi/phpdbg/phpdbg")) { - $phpdbg = realpath("./sapi/phpdbg/phpdbg"); - } elseif (file_exists(dirname($php) . "/phpdbg")) { - $phpdbg = realpath(dirname($php) . "/phpdbg"); - } else { - $phpdbg = null; - } - if ($phpdbg) { - putenv("TEST_PHPDBG_EXECUTABLE=$phpdbg"); - } - } - - if (getenv('TEST_PHPDBG_EXECUTABLE')) { - $phpdbg = getenv('TEST_PHPDBG_EXECUTABLE'); - - if ($phpdbg == 'auto') { - $phpdbg = TEST_PHP_SRCDIR . '/sapi/phpdbg/phpdbg'; - putenv("TEST_PHPDBG_EXECUTABLE=$phpdbg"); - } - - $environment['TEST_PHPDBG_EXECUTABLE'] = $phpdbg; - } - if (getenv('TEST_PHP_LOG_FORMAT')) { $log_format = strtoupper(getenv('TEST_PHP_LOG_FORMAT')); } else { @@ -302,7 +260,7 @@ function main(): void $DETAILED = 0; } - junit_init(); + $junit = new JUnit($environment, $workerID); if (getenv('SHOW_ONLY_GROUPS')) { $SHOW_ONLY_GROUPS = explode(",", getenv('SHOW_ONLY_GROUPS')); @@ -341,7 +299,6 @@ function main(): void 'precision=14', 'serialize_precision=-1', 'memory_limit=128M', - 'log_errors_max_len=0', 'opcache.fast_shutdown=0', 'opcache.file_update_protection=0', 'opcache.revalidate_freq=0', @@ -349,6 +306,10 @@ function main(): void 'opcache.jit_hot_func=1', 'opcache.jit_hot_return=1', 'opcache.jit_hot_side_exit=1', + 'opcache.jit_max_root_traces=100000', + 'opcache.jit_max_side_traces=100000', + 'opcache.jit_max_exit_counters=100000', + 'opcache.protect_memory=1', 'zend.assertions=1', 'zend.exception_ignore_args=0', 'zend.exception_string_param_max_len=15', @@ -357,9 +318,6 @@ function main(): void $no_file_cache = '-d opcache.file_cache= -d opcache.file_cache_only=0'; - define('PHP_QA_EMAIL', 'qa-reports@lists.php.net'); - define('QA_SUBMISSION_PAGE', 'http://qa.php.net/buildtest-process.php'); - define('QA_REPORTS_PAGE', 'http://qa.php.net/reports'); define('TRAVIS_CI', (bool) getenv('TRAVIS')); // Determine the tests to be run. @@ -395,7 +353,7 @@ function main(): void if (function_exists('sapi_windows_vt100_support') && !sapi_windows_vt100_support(STDOUT, true)) { $colorize = false; } - if (array_key_exists('NO_COLOR', $_ENV)) { + if (array_key_exists('NO_COLOR', $environment)) { $colorize = false; } $selected_tests = false; @@ -403,8 +361,12 @@ function main(): void $preload = false; $file_cache = null; $shuffle = false; + $bless = false; $workers = null; $context_line_count = 3; + $num_repeats = 1; + $show_progress = true; + $ignored_by_ext = []; $cfgtypes = ['show', 'keep']; $cfgfiles = ['skip', 'php', 'clean', 'out', 'diff', 'exp', 'mem']; @@ -440,15 +402,13 @@ function main(): void $is_switch = true; - if ($repeat) { - foreach ($cfgtypes as $type) { - if (strpos($switch, '--' . $type) === 0) { - foreach ($cfgfiles as $file) { - if ($switch == '--' . $type . '-' . $file) { - $cfg[$type][$file] = true; - $is_switch = false; - break; - } + foreach ($cfgtypes as $type) { + if (strpos($switch, '--' . $type) === 0) { + foreach ($cfgfiles as $file) { + if ($switch == '--' . $type . '-' . $file) { + $cfg[$type][$file] = true; + $is_switch = false; + break; } } } @@ -464,7 +424,7 @@ function main(): void switch ($switch) { case 'j': $workers = substr($argv[$i], 2); - if (!preg_match('/^\d+$/', $workers) || $workers == 0) { + if ($workers == 0 || !preg_match('/^\d+$/', $workers)) { error("'$workers' is not a valid number of workers, try e.g. -j16 for 16 workers"); } $workers = intval($workers, 10); @@ -481,10 +441,8 @@ function main(): void $matches = []; if (preg_match('/^#.*\[(.*)\]\:\s+(.*)$/', $test, $matches)) { $redir_tests[] = [$matches[1], $matches[2]]; - } else { - if (strlen($test)) { - $test_files[] = trim($test); - } + } elseif (strlen($test)) { + $test_files[] = trim($test); } } } @@ -511,13 +469,11 @@ function main(): void case 'g': $SHOW_ONLY_GROUPS = explode(",", $argv[++$i]); break; - //case 'h' case '--keep-all': foreach ($cfgfiles as $file) { $cfg['keep'][$file] = true; } break; - //case 'l' case 'm': $valgrind = new RuntestsValgrind($environment); break; @@ -535,6 +491,7 @@ function main(): void break; case '--preload': $preload = true; + $environment['SKIP_PRELOAD'] = 1; break; case '--file-cache-prime': $file_cache = 'prime'; @@ -565,7 +522,6 @@ function main(): void putenv('NO_INTERACTION=1'); $environment['NO_INTERACTION'] = 1; break; - //case 'r' case 's': $output_file = $argv[++$i]; $just_save_results = true; @@ -623,15 +579,22 @@ function main(): void $environment['SKIP_PERF_SENSITIVE'] = 1; if ($switch === '--msan') { $environment['SKIP_MSAN'] = 1; + $environment['MSAN_OPTIONS'] = 'intercept_tls_get_addr=0'; } - $lsanSuppressions = __DIR__ . '/azure/lsan-suppressions.txt'; + $lsanSuppressions = __DIR__ . '/.github/lsan-suppressions.txt'; if (file_exists($lsanSuppressions)) { $environment['LSAN_OPTIONS'] = 'suppressions=' . $lsanSuppressions . ':print_suppressions=0'; } break; - //case 'w' + case '--repeat': + $num_repeats = (int) $argv[++$i]; + $environment['SKIP_REPEAT'] = 1; + break; + case '--bless': + $bless = true; + break; case '-': // repeat check with full switch $switch = $argv[$i]; @@ -639,8 +602,14 @@ function main(): void $repeat = true; } break; + case '--progress': + $show_progress = true; + break; + case '--no-progress': + $show_progress = false; + break; case '--version': - echo '$Id$' . "\n"; + echo '$Id: 116987071ada4defb2077b73099cbb851890f0af $' . "\n"; exit(1); default: @@ -661,27 +630,21 @@ function main(): void if (!$testfile && strpos($argv[$i], '*') !== false && function_exists('glob')) { if (substr($argv[$i], -5) == '.phpt') { $pattern_match = glob($argv[$i]); + } elseif (preg_match("/\*$/", $argv[$i])) { + $pattern_match = glob($argv[$i] . '.phpt'); } else { - if (preg_match("/\*$/", $argv[$i])) { - $pattern_match = glob($argv[$i] . '.phpt'); - } else { - die('Cannot find test file "' . $argv[$i] . '".' . PHP_EOL); - } + die('Cannot find test file "' . $argv[$i] . '".' . PHP_EOL); } if (is_array($pattern_match)) { $test_files = array_merge($test_files, $pattern_match); } + } elseif (is_dir($testfile)) { + find_files($testfile); + } elseif (substr($testfile, -5) == '.phpt') { + $test_files[] = $testfile; } else { - if (is_dir($testfile)) { - find_files($testfile); - } else { - if (substr($testfile, -5) == '.phpt') { - $test_files[] = $testfile; - } else { - die('Cannot find test file "' . $argv[$i] . '".' . PHP_EOL); - } - } + die('Cannot find test file "' . $argv[$i] . '".' . PHP_EOL); } } } @@ -691,14 +654,41 @@ function main(): void return; } - // Default to PHP_BINARY as executable - if (!isset($environment['TEST_PHP_EXECUTABLE'])) { + if (!$php) { + $php = getenv('TEST_PHP_EXECUTABLE'); + } + if (!$php) { $php = PHP_BINARY; - putenv("TEST_PHP_EXECUTABLE=$php"); - $environment['TEST_PHP_EXECUTABLE'] = $php; } - if ($conf_passed != null) { + if (!$php_cgi) { + $php_cgi = getenv('TEST_PHP_CGI_EXECUTABLE'); + } + if (!$php_cgi) { + $php_cgi = get_binary($php, 'php-cgi', 'sapi/cgi/php-cgi'); + } + + if (!$phpdbg) { + $phpdbg = getenv('TEST_PHPDBG_EXECUTABLE'); + } + if (!$phpdbg) { + $phpdbg = get_binary($php, 'phpdbg', 'sapi/phpdbg/phpdbg'); + } + + putenv("TEST_PHP_EXECUTABLE=$php"); + $environment['TEST_PHP_EXECUTABLE'] = $php; + putenv("TEST_PHP_EXECUTABLE_ESCAPED=" . escapeshellarg($php)); + $environment['TEST_PHP_EXECUTABLE_ESCAPED'] = escapeshellarg($php); + putenv("TEST_PHP_CGI_EXECUTABLE=$php_cgi"); + $environment['TEST_PHP_CGI_EXECUTABLE'] = $php_cgi; + putenv("TEST_PHP_CGI_EXECUTABLE_ESCAPED=" . escapeshellarg($php_cgi ?? '')); + $environment['TEST_PHP_CGI_EXECUTABLE_ESCAPED'] = escapeshellarg($php_cgi ?? ''); + putenv("TEST_PHPDBG_EXECUTABLE=$phpdbg"); + $environment['TEST_PHPDBG_EXECUTABLE'] = $phpdbg; + putenv("TEST_PHPDBG_EXECUTABLE_ESCAPED=" . escapeshellarg($phpdbg ?? '')); + $environment['TEST_PHPDBG_EXECUTABLE_ESCAPED'] = escapeshellarg($phpdbg ?? ''); + + if ($conf_passed !== null) { if (IS_WINDOWS) { $pass_options .= " -c " . escapeshellarg($conf_passed); } else { @@ -744,14 +734,13 @@ function main(): void echo get_summary(false); if ($output_file != '' && $just_save_results) { - save_or_mail_results(); + save_results($output_file, /* prompt_to_save_results: */ false); } } else { // Compile a list of all test files (*.phpt). $test_files = []; - $exts_tested = count($exts_to_test); - $exts_skipped = 0; - $ignored_by_ext = 0; + $exts_tested = $exts_to_test; + $exts_skipped = []; sort($exts_to_test); $test_dirs = []; $optionals = ['Zend', 'tests', 'ext', 'sapi']; @@ -762,11 +751,6 @@ function main(): void } } - // Convert extension names to lowercase - foreach ($exts_to_test as $key => $val) { - $exts_to_test[$key] = strtolower($val); - } - foreach ($test_dirs as $dir) { find_files(TEST_PHP_SRCDIR . "/{$dir}", $dir == 'ext'); } @@ -806,10 +790,13 @@ function main(): void show_end($end_time); show_summary(); - save_or_mail_results(); + save_results($output_file, /* prompt_to_save_results: */ true); } - junit_save_xml(); + $junit->saveXML(); + if ($bless) { + bless_failed_tests($PHP_FAILED_TESTS['FAILED']); + } if (getenv('REPORT_EXIT_STATUS') !== '0' && getenv('REPORT_EXIT_STATUS') !== 'no' && ($sum_results['FAILED'] || $sum_results['BORKED'] || $sum_results['LEAKED'])) { exit(1); @@ -849,6 +836,7 @@ function verify_config(): void function write_information(): void { global $php, $php_cgi, $phpdbg, $php_info, $user_tests, $ini_overwrites, $pass_options, $exts_to_test, $valgrind, $no_file_cache; + $php_escaped = escapeshellarg($php); // Get info from php $info_file = __DIR__ . '/run-test-info.php'; @@ -864,11 +852,12 @@ function write_information(): void $info_params = []; settings2array($ini_overwrites, $info_params); $info_params = settings2params($info_params); - $php_info = `$php $pass_options $info_params $no_file_cache "$info_file"`; - define('TESTED_PHP_VERSION', `$php -n -r "echo PHP_VERSION;"`); + $php_info = shell_exec("$php_escaped $pass_options $info_params $no_file_cache \"$info_file\""); + define('TESTED_PHP_VERSION', shell_exec("$php_escaped -n -r \"echo PHP_VERSION;\"")); if ($php_cgi && $php != $php_cgi) { - $php_info_cgi = `$php_cgi $pass_options $info_params $no_file_cache -q "$info_file"`; + $php_cgi_escaped = escapeshellarg($php_cgi); + $php_info_cgi = shell_exec("$php_cgi_escaped $pass_options $info_params $no_file_cache -q \"$info_file\""); $php_info_sep = "\n---------------------------------------------------------------------"; $php_cgi_info = "$php_info_sep\nPHP : $php_cgi $php_info_cgi$php_info_sep"; } else { @@ -876,7 +865,8 @@ function write_information(): void } if ($phpdbg) { - $phpdbg_info = `$phpdbg $pass_options $info_params $no_file_cache -qrr "$info_file"`; + $phpdbg_escaped = escapeshellarg($phpdbg); + $phpdbg_info = shell_exec("$phpdbg_escaped $pass_options $info_params $no_file_cache -qrr \"$info_file\""); $php_info_sep = "\n---------------------------------------------------------------------"; $phpdbg_info = "$php_info_sep\nPHP : $phpdbg $phpdbg_info$php_info_sep"; } else { @@ -888,17 +878,28 @@ function write_information(): void } @unlink($info_file); - // load list of enabled extensions - save_text($info_file, - ''); - $exts_to_test = explode(',', `$php $pass_options $info_params $no_file_cache "$info_file"`); + // load list of enabled and loadable extensions + save_text($info_file, <<<'PHP' + ['session.auto_start=0'], 'tidy' => ['tidy.clean_output=0'], 'zlib' => ['zlib.output_compression=Off'], 'xdebug' => ['xdebug.mode=off'], - 'mbstring' => ['mbstring.func_overload=0'], ]; foreach ($info_params_ex as $ext => $ini_overwrites_ex) { @@ -927,134 +928,119 @@ function write_information(): void "; } -function save_or_mail_results(): void +function save_results(string $output_file, bool $prompt_to_save_results): void { - global $sum_results, $just_save_results, $failed_test_summary, - $PHP_FAILED_TESTS, $php, $output_file; + global $sum_results, $failed_test_summary, + $PHP_FAILED_TESTS, $php; + + if (getenv('NO_INTERACTION') || TRAVIS_CI) { + return; + } - /* We got failed Tests, offer the user to send an e-mail to QA team, unless NO_INTERACTION is set */ - if (!getenv('NO_INTERACTION') && !TRAVIS_CI) { + if ($prompt_to_save_results) { + /* We got failed Tests, offer the user to save a QA report */ $fp = fopen("php://stdin", "r+"); if ($sum_results['FAILED'] || $sum_results['BORKED'] || $sum_results['WARNED'] || $sum_results['LEAKED']) { echo "\nYou may have found a problem in PHP."; } - echo "\nThis report can be automatically sent to the PHP QA team at\n"; - echo QA_REPORTS_PAGE . " and http://news.php.net/php.qa.reports\n"; + echo "\nThis report can be saved and used to open an issue on the bug tracker at\n"; + echo "https://github.com/php/php-src/issues\n"; echo "This gives us a better understanding of PHP's behavior.\n"; - echo "If you don't want to send the report immediately you can choose\n"; - echo "option \"s\" to save it. You can then email it to " . PHP_QA_EMAIL . " later.\n"; - echo "Do you want to send this report now? [Yns]: "; + echo "Do you want to save this report in a file? [Yn]: "; flush(); $user_input = fgets($fp, 10); - $just_save_results = (!empty($user_input) && strtolower($user_input[0]) === 's'); - } - - if ($just_save_results || !getenv('NO_INTERACTION') || TRAVIS_CI) { - if ($just_save_results || TRAVIS_CI || strlen(trim($user_input)) == 0 || strtolower($user_input[0]) == 'y') { - /* - * Collect information about the host system for our report - * Fetch phpinfo() output so that we can see the PHP environment - * Make an archive of all the failed tests - * Send an email - */ - if ($just_save_results) { - $user_input = 's'; - } - - /* Ask the user to provide an email address, so that QA team can contact the user */ - if (TRAVIS_CI) { - $user_email = 'travis at php dot net'; - } elseif (!strncasecmp($user_input, 'y', 1) || strlen(trim($user_input)) == 0) { - echo "\nPlease enter your email address.\n(Your address will be mangled so that it will not go out on any\nmailinglist in plain text): "; - flush(); - $user_email = trim(fgets($fp, 1024)); - $user_email = str_replace("@", " at ", str_replace(".", " dot ", $user_email)); - } - - $failed_tests_data = ''; - $sep = "\n" . str_repeat('=', 80) . "\n"; - $failed_tests_data .= $failed_test_summary . "\n"; - $failed_tests_data .= get_summary(true) . "\n"; - - if ($sum_results['FAILED']) { - foreach ($PHP_FAILED_TESTS['FAILED'] as $test_info) { - $failed_tests_data .= $sep . $test_info['name'] . $test_info['info']; - $failed_tests_data .= $sep . file_get_contents(realpath($test_info['output'])); - $failed_tests_data .= $sep . file_get_contents(realpath($test_info['diff'])); - $failed_tests_data .= $sep . "\n\n"; - } - $status = "failed"; - } else { - $status = "success"; - } - - $failed_tests_data .= "\n" . $sep . 'BUILD ENVIRONMENT' . $sep; - $failed_tests_data .= "OS:\n" . PHP_OS . " - " . php_uname() . "\n\n"; - $ldd = $autoconf = $sys_libtool = $libtool = $compiler = 'N/A'; - - if (!IS_WINDOWS) { - /* If PHP_AUTOCONF is set, use it; otherwise, use 'autoconf'. */ - if (getenv('PHP_AUTOCONF')) { - $autoconf = shell_exec(getenv('PHP_AUTOCONF') . ' --version'); - } else { - $autoconf = shell_exec('autoconf --version'); - } - - /* Always use the generated libtool - Mac OSX uses 'glibtool' */ - $libtool = shell_exec(INIT_DIR . '/libtool --version'); + fclose($fp); + if (!(strlen(trim($user_input)) == 0 || strtolower($user_input[0]) == 'y')) { + return; + } + } + /** + * Collect information about the host system for our report + * Fetch phpinfo() output so that we can see the PHP environment + * Make an archive of all the failed tests + */ + $failed_tests_data = ''; + $sep = "\n" . str_repeat('=', 80) . "\n"; + $failed_tests_data .= $failed_test_summary . "\n"; + $failed_tests_data .= get_summary(true) . "\n"; + + if ($sum_results['FAILED']) { + foreach ($PHP_FAILED_TESTS['FAILED'] as $test_info) { + $failed_tests_data .= $sep . $test_info['name'] . $test_info['info']; + $failed_tests_data .= $sep . file_get_contents(realpath($test_info['output'])); + $failed_tests_data .= $sep . file_get_contents(realpath($test_info['diff'])); + $failed_tests_data .= $sep . "\n\n"; + } + } - /* Use shtool to find out if there is glibtool present (MacOSX) */ - $sys_libtool_path = shell_exec(__DIR__ . '/build/shtool path glibtool libtool'); + $failed_tests_data .= "\n" . $sep . 'BUILD ENVIRONMENT' . $sep; + $failed_tests_data .= "OS:\n" . PHP_OS . " - " . php_uname() . "\n\n"; + $ldd = $autoconf = $sys_libtool = $libtool = $compiler = 'N/A'; - if ($sys_libtool_path) { - $sys_libtool = shell_exec(str_replace("\n", "", $sys_libtool_path) . ' --version'); - } + if (!IS_WINDOWS) { + /* If PHP_AUTOCONF is set, use it; otherwise, use 'autoconf'. */ + if (getenv('PHP_AUTOCONF')) { + $autoconf = shell_exec(getenv('PHP_AUTOCONF') . ' --version'); + } else { + $autoconf = shell_exec('autoconf --version'); + } - /* Try the most common flags for 'version' */ - $flags = ['-v', '-V', '--version']; - $cc_status = 0; + /* Always use the generated libtool - Mac OSX uses 'glibtool' */ + $libtool = shell_exec(INIT_DIR . '/libtool --version'); - foreach ($flags as $flag) { - system(getenv('CC') . " $flag >/dev/null 2>&1", $cc_status); - if ($cc_status == 0) { - $compiler = shell_exec(getenv('CC') . " $flag 2>&1"); - break; - } - } + /* Use shtool to find out if there is glibtool present (MacOSX) */ + $sys_libtool_path = shell_exec(__DIR__ . '/build/shtool path glibtool libtool'); - $ldd = shell_exec("ldd $php 2>/dev/null"); - } + if ($sys_libtool_path) { + $sys_libtool = shell_exec(str_replace("\n", "", $sys_libtool_path) . ' --version'); + } - $failed_tests_data .= "Autoconf:\n$autoconf\n"; - $failed_tests_data .= "Bundled Libtool:\n$libtool\n"; - $failed_tests_data .= "System Libtool:\n$sys_libtool\n"; - $failed_tests_data .= "Compiler:\n$compiler\n"; - $failed_tests_data .= "Bison:\n" . shell_exec('bison --version 2>/dev/null') . "\n"; - $failed_tests_data .= "Libraries:\n$ldd\n"; - $failed_tests_data .= "\n"; + /* Try the most common flags for 'version' */ + $flags = ['-v', '-V', '--version']; + $cc_status = 0; - if (isset($user_email)) { - $failed_tests_data .= "User's E-mail: " . $user_email . "\n\n"; + foreach ($flags as $flag) { + system(getenv('CC') . " $flag >/dev/null 2>&1", $cc_status); + if ($cc_status == 0) { + $compiler = shell_exec(getenv('CC') . " $flag 2>&1"); + break; } + } - $failed_tests_data .= $sep . "PHPINFO" . $sep; - $failed_tests_data .= shell_exec($php . ' -ddisplay_errors=stderr -dhtml_errors=0 -i 2> /dev/null'); + $ldd = shell_exec("ldd $php 2>/dev/null"); + } - if (($just_save_results || !mail_qa_team($failed_tests_data, $status)) && !TRAVIS_CI) { - file_put_contents($output_file, $failed_tests_data); + $failed_tests_data .= "Autoconf:\n$autoconf\n"; + $failed_tests_data .= "Bundled Libtool:\n$libtool\n"; + $failed_tests_data .= "System Libtool:\n$sys_libtool\n"; + $failed_tests_data .= "Compiler:\n$compiler\n"; + $failed_tests_data .= "Bison:\n" . shell_exec('bison --version 2>/dev/null') . "\n"; + $failed_tests_data .= "Libraries:\n$ldd\n"; + $failed_tests_data .= "\n"; + $failed_tests_data .= $sep . "PHPINFO" . $sep; + $failed_tests_data .= shell_exec($php . ' -ddisplay_errors=stderr -dhtml_errors=0 -i 2> /dev/null'); - if (!$just_save_results) { - echo "\nThe test script was unable to automatically send the report to PHP's QA Team\n"; - } + file_put_contents($output_file, $failed_tests_data); + echo "Report saved to: ", $output_file, "\n"; +} - echo "Please send " . $output_file . " to " . PHP_QA_EMAIL . " manually, thank you.\n"; - } elseif (!getenv('NO_INTERACTION') && !TRAVIS_CI) { - fwrite($fp, "\nThank you for helping to make PHP better.\n"); - fclose($fp); - } - } +function get_binary(string $php, string $sapi, string $sapi_path): ?string +{ + $dir = dirname($php); + if (IS_WINDOWS && file_exists("$dir/$sapi.exe")) { + return realpath("$dir/$sapi.exe"); } + // Sources tree + if (file_exists("$dir/../../$sapi_path")) { + return realpath("$dir/../../$sapi_path"); + } + // Installation tree, preserve command prefix/suffix + $inst = str_replace('php', $sapi, basename($php)); + if (file_exists("$dir/$inst")) { + return realpath("$dir/$inst"); + } + return null; } function find_files(string $dir, bool $is_ext_dir = false, bool $ignore = false): void @@ -1065,9 +1051,9 @@ function find_files(string $dir, bool $is_ext_dir = false, bool $ignore = false) while (($name = readdir($o)) !== false) { if (is_dir("{$dir}/{$name}") && !in_array($name, ['.', '..', '.svn'])) { - $skip_ext = ($is_ext_dir && !in_array(strtolower($name), $exts_to_test)); + $skip_ext = ($is_ext_dir && !in_array($name, $exts_to_test)); if ($skip_ext) { - $exts_skipped++; + $exts_skipped[] = $name; } find_files("{$dir}/{$name}", false, $ignore || $skip_ext); } @@ -1079,11 +1065,13 @@ function find_files(string $dir, bool $is_ext_dir = false, bool $ignore = false) } // Otherwise we're only interested in *.phpt files. - if (substr($name, -5) == '.phpt') { + // (but not those starting with a dot, which are hidden on + // many platforms) + if (substr($name, -5) == '.phpt' && substr($name, 0, 1) !== '.') { + $testfile = realpath("{$dir}/{$name}"); if ($ignore) { - $ignored_by_ext++; + $ignored_by_ext[] = $testfile; } else { - $testfile = realpath("{$dir}/{$name}"); $test_files[] = $testfile; } } @@ -1099,9 +1087,9 @@ function test_name($name): string { if (is_array($name)) { return $name[0] . ':' . $name[1]; - } else { - return $name; } + + return $name; } /** * @param array|string $a @@ -1119,55 +1107,9 @@ function test_sort($a, $b): int if ($ta == $tb) { return strcmp($a, $b); - } else { - return $tb - $ta; - } -} - -// -// Send Email to QA Team -// - -function mail_qa_team(string $data, bool $status = false): bool -{ - $url_bits = parse_url(QA_SUBMISSION_PAGE); - - if ($proxy = getenv('http_proxy')) { - $proxy = parse_url($proxy); - $path = $url_bits['host'] . $url_bits['path']; - $host = $proxy['host']; - if (empty($proxy['port'])) { - $proxy['port'] = 80; - } - $port = $proxy['port']; - } else { - $path = $url_bits['path']; - $host = $url_bits['host']; - $port = empty($url_bits['port']) ? 80 : $port = $url_bits['port']; - } - - $data = "php_test_data=" . urlencode(base64_encode(str_replace("\00", '[0x0]', $data))); - $data_length = strlen($data); - - $fs = fsockopen($host, $port, $errno, $errstr, 10); - - if (!$fs) { - return false; } - $php_version = urlencode(TESTED_PHP_VERSION); - - echo "\nPosting to " . QA_SUBMISSION_PAGE . "\n"; - fwrite($fs, "POST " . $path . "?status=$status&version=$php_version HTTP/1.1\r\n"); - fwrite($fs, "Host: " . $host . "\r\n"); - fwrite($fs, "User-Agent: QA Browser 0.1\r\n"); - fwrite($fs, "Content-Type: application/x-www-form-urlencoded\r\n"); - fwrite($fs, "Content-Length: " . $data_length . "\r\n\r\n"); - fwrite($fs, $data); - fwrite($fs, "\r\n\r\n"); - fclose($fs); - - return true; + return $tb - $ta; } // @@ -1178,10 +1120,8 @@ function save_text(string $filename, string $text, ?string $filename_copy = null { global $DETAILED; - if ($filename_copy && $filename_copy != $filename) { - if (file_put_contents($filename_copy, $text) === false) { - error("Cannot open file '" . $filename_copy . "' (save_text)"); - } + if ($filename_copy && $filename_copy != $filename && file_put_contents($filename_copy, $text) === false) { + error("Cannot open file '" . $filename_copy . "' (save_text)"); } if (file_put_contents($filename, $text) === false) { @@ -1231,6 +1171,13 @@ function system_with_timeout( ) { global $valgrind; + // when proc_open cmd is passed as a string (without bypass_shell=true option) the cmd goes thru shell + // and on Windows quotes are discarded, this is a fix to honor the quotes and allow values containing + // spaces like '"C:\Program Files\PHP\php.exe"' to be passed as 1 argument correctly + if (IS_WINDOWS) { + $commandline = 'start "" /b /wait ' . $commandline; + } + $data = ''; $bin_env = []; @@ -1263,6 +1210,10 @@ function system_with_timeout( } $timeout = $valgrind ? 300 : ($env['TEST_TIMEOUT'] ?? 60); + /* ASAN can cause a ~2-3x slowdown. */ + if (isset($env['SKIP_ASAN'])) { + $timeout *= 3; + } while (true) { /* hide errors from interrupted syscalls */ @@ -1274,12 +1225,16 @@ function system_with_timeout( if ($n === false) { break; - } elseif ($n === 0) { + } + + if ($n === 0) { /* timed out */ $data .= "\n ** ERROR: process timed out **\n"; proc_terminate($proc, 9); return $data; - } elseif ($n > 0) { + } + + if ($n > 0) { if ($captureStdOut) { $line = fread($pipes[1], 8192); } elseif ($captureStdErr) { @@ -1317,14 +1272,24 @@ function system_with_timeout( function run_all_tests(array $test_files, array $env, $redir_tested = null): void { global $test_results, $failed_tests_file, $result_tests_file, $php, $test_idx, $file_cache; + global $preload; // Parallel testing global $PHP_FAILED_TESTS, $workers, $workerID, $workerSock; - if ($file_cache !== null) { - /* Automatically skip opcache tests in --file-cache mode, - * because opcache generally doesn't expect those to run under file cache */ - $test_files = array_filter($test_files, function ($test) { - return !is_string($test) || false === strpos($test, 'ext/opcache'); + if ($file_cache !== null || $preload) { + /* Automatically skip opcache tests in --file-cache and --preload mode, + * because opcache generally expects these to run under a default configuration. */ + $test_files = array_filter($test_files, function($test) use($preload) { + if (!is_string($test)) { + return true; + } + if (false !== strpos($test, 'ext/opcache')) { + return false; + } + if ($preload && false !== strpos($test, 'ext/zend_test/tests/observer')) { + return false; + } + return true; }); } @@ -1387,7 +1352,9 @@ function run_all_tests(array $test_files, array $env, $redir_tested = null): voi */ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): void { - global $workers, $test_idx, $test_cnt, $test_results, $failed_tests_file, $result_tests_file, $PHP_FAILED_TESTS, $shuffle, $SHOW_ONLY_GROUPS, $valgrind; + global $workers, $test_idx, $test_cnt, $test_results, $failed_tests_file, $result_tests_file, $PHP_FAILED_TESTS, $shuffle, $SHOW_ONLY_GROUPS, $valgrind, $show_progress; + + global $junit; // The PHP binary running run-tests.php, and run-tests.php itself // This PHP executable is *not* necessarily the same as the tested version @@ -1397,10 +1364,6 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v $workerProcs = []; $workerSocks = []; - echo "=====================================================================\n"; - echo "========= WELCOME TO THE FUTURE: run-tests PARALLEL EDITION =========\n"; - echo "=====================================================================\n"; - // Each test may specify a list of conflict keys. While a test that conflicts with // key K is running, no other test that conflicts with K may run. Conflict keys are // specified either in the --CONFLICTS-- section, or CONFLICTS file inside a directory. @@ -1467,11 +1430,11 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v $startTime = microtime(true); for ($i = 1; $i <= $workers; $i++) { $proc = proc_open( - $thisPHP . ' ' . escapeshellarg($thisScript), + [$thisPHP, $thisScript], [], // Inherit our stdin, stdout and stderr $pipes, null, - $_ENV + [ + $GLOBALS['environment'] + [ "TEST_PHP_WORKER" => $i, "TEST_PHP_URI" => $sockUri, ], @@ -1500,9 +1463,6 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v "constants" => [ "INIT_DIR" => INIT_DIR, "TEST_PHP_SRCDIR" => TEST_PHP_SRCDIR, - "PHP_QA_EMAIL" => PHP_QA_EMAIL, - "QA_SUBMISSION_PAGE" => QA_SUBMISSION_PAGE, - "QA_REPORTS_PAGE" => QA_REPORTS_PAGE, "TRAVIS_CI" => TRAVIS_CI ] ])) . "\n"; @@ -1555,6 +1515,10 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v kill_children($workerProcs); error("Could not find worker stdout in array of worker stdouts, THIS SHOULD NOT HAPPEN."); } + if (feof($workerSock)) { + kill_children($workerProcs); + error("Worker $i died unexpectedly"); + } while (false !== ($rawMessage = fgets($workerSock))) { // work around fgets truncating things if (($rawMessageBuffers[$i] ?? '') !== '') { @@ -1587,9 +1551,7 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v } } } - if (junit_enabled()) { - junit_merge_results($message["junit"]); - } + $junit->mergeResults($message["junit"]); // no break case "ready": // Schedule sequential tests only once we are down to one worker. @@ -1630,8 +1592,7 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v ]); } else { proc_terminate($workerProcs[$i]); - unset($workerProcs[$i]); - unset($workerSocks[$i]); + unset($workerProcs[$i], $workerSocks[$i]); goto escape; } break; @@ -1642,13 +1603,13 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v } $test_idx++; - if (!$SHOW_ONLY_GROUPS) { + if ($show_progress) { clear_show_test(); } echo $resultText; - if (!$SHOW_ONLY_GROUPS) { + if ($show_progress) { show_test($test_idx, count($workerProcs) . "/$workers concurrent test workers running"); } @@ -1698,7 +1659,7 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v } } - if (!$SHOW_ONLY_GROUPS) { + if ($show_progress) { clear_show_test(); } @@ -1709,11 +1670,47 @@ function run_all_tests_parallel(array $test_files, array $env, $redir_tested): v } } +/** + * Calls fwrite and retries when network writes fail with errors such as "Resource temporarily unavailable" + * + * @param resource $stream the stream to fwrite to + * @param string $data + * @return int|false + */ +function safe_fwrite($stream, string $data) +{ + // safe_fwrite was tested by adding $message['unused'] = str_repeat('a', 20_000_000); in send_message() + // fwrites on tcp sockets can return false or less than strlen if the recipient is busy. + // (e.g. fwrite(): Send of 577 bytes failed with errno=35 Resource temporarily unavailable) + $bytes_written = 0; + while ($bytes_written < strlen($data)) { + $n = @fwrite($stream, substr($data, $bytes_written)); + if ($n === false) { + $write_streams = [$stream]; + $read_streams = []; + $except_streams = []; + /* Wait for up to 10 seconds for the stream to be ready to write again. */ + $result = stream_select($read_streams, $write_streams, $except_streams, 10); + if (!$result) { + echo "ERROR: send_message() stream_select() failed\n"; + return false; + } + $n = @fwrite($stream, substr($data, $bytes_written)); + if ($n === false) { + echo "ERROR: send_message() Failed to write chunk after stream_select: " . error_get_last()['message'] . "\n"; + return false; + } + } + $bytes_written += $n; + } + return $bytes_written; +} + function send_message($stream, array $message): void { $blocking = stream_get_meta_data($stream)["blocked"]; stream_set_blocking($stream, true); - fwrite($stream, base64_encode(serialize($message)) . "\n"); + safe_fwrite($stream, base64_encode(serialize($message)) . "\n"); stream_set_blocking($stream, $blocking); } @@ -1730,6 +1727,8 @@ function run_worker(): void { global $workerID, $workerSock; + global $junit; + $sockUri = getenv("TEST_PHP_URI"); $workerSock = stream_socket_client($sockUri, $_, $_, 5) or error("Couldn't connect to $sockUri"); @@ -1776,9 +1775,9 @@ function run_worker(): void run_all_tests($command["test_files"], $command["env"], $command["redir_tested"]); send_message($workerSock, [ "type" => "tests_finished", - "junit" => junit_enabled() ? $GLOBALS['JUNIT'] : null, + "junit" => $junit->isEnabled() ? $junit : null, ]); - junit_init(); + $junit->clear(); break; default: send_message($workerSock, [ @@ -1814,6 +1813,16 @@ function show_file_block(string $file, string $block, ?string $section = null): } } +function skip_test(string $tested, string $tested_file, string $shortname, string $reason): string +{ + global $junit; + + show_result('SKIP', $tested, $tested_file, "reason: $reason"); + $junit->initSuite($junit->getSuiteName($shortname)); + $junit->markTestAs('SKIP', $shortname, $tested, 0, $reason); + return 'SKIPPED'; +} + // // Run an individual test case. // @@ -1830,19 +1839,33 @@ function run_test(string $php, $file, array $env): string global $no_file_cache; global $slow_min_ms; global $preload, $file_cache; + global $num_repeats; // Parallel testing global $workerID; - $temp_filenames = null; - $org_file = $file; + global $show_progress; - if (isset($env['TEST_PHP_CGI_EXECUTABLE'])) { - $php_cgi = $env['TEST_PHP_CGI_EXECUTABLE']; - } + // Temporary + /** @var JUnit $junit */ + global $junit; - if (isset($env['TEST_PHPDBG_EXECUTABLE'])) { - $phpdbg = $env['TEST_PHPDBG_EXECUTABLE']; + static $skipCache; + if (!$skipCache) { + $enableSkipCache = !($env['DISABLE_SKIP_CACHE'] ?? '0'); + $skipCache = new SkipCache($enableSkipCache, $cfg['keep']['skip']); } + $orig_php = $php; + $php = escapeshellarg($php); + + $retried = false; +retry: + + $temp_filenames = null; + $org_file = $file; + + $php_cgi = $env['TEST_PHP_CGI_EXECUTABLE'] ?? null; + $phpdbg = $env['TEST_PHPDBG_EXECUTABLE'] ?? null; + if (is_array($file)) { $file = $file[0]; } @@ -1854,136 +1877,38 @@ function run_test(string $php, $file, array $env): string "; } - // Load the sections of the test file. - $section_text = ['TEST' => '']; - - $fp = fopen($file, "rb") or error("Cannot open test file: $file"); - - $bork_info = null; - - if (!feof($fp)) { - $line = fgets($fp); - - if ($line === false) { - $bork_info = "cannot read test"; - } - } else { - $bork_info = "empty test [$file]"; - } - if ($bork_info === null && strncmp('--TEST--', $line, 8)) { - $bork_info = "tests must start with --TEST-- [$file]"; - } - - $section = 'TEST'; - $secfile = false; - $secdone = false; - - while (!feof($fp)) { - $line = fgets($fp); - - if ($line === false) { - break; - } - - // Match the beginning of a section. - if (preg_match('/^--([_A-Z]+)--/', $line, $r)) { - $section = (string) $r[1]; - - if (isset($section_text[$section]) && $section_text[$section]) { - $bork_info = "duplicated $section section"; - } - - // check for unknown sections - if (!in_array($section, [ - 'EXPECT', 'EXPECTF', 'EXPECTREGEX', 'EXPECTREGEX_EXTERNAL', 'EXPECT_EXTERNAL', 'EXPECTF_EXTERNAL', 'EXPECTHEADERS', - 'POST', 'POST_RAW', 'GZIP_POST', 'DEFLATE_POST', 'PUT', 'GET', 'COOKIE', 'ARGS', - 'FILE', 'FILEEOF', 'FILE_EXTERNAL', 'REDIRECTTEST', - 'CAPTURE_STDIO', 'STDIN', 'CGI', 'PHPDBG', - 'INI', 'ENV', 'EXTENSIONS', - 'SKIPIF', 'XFAIL', 'XLEAK', 'CLEAN', - 'CREDITS', 'DESCRIPTION', 'CONFLICTS', 'WHITESPACE_SENSITIVE', - ])) { - $bork_info = 'Unknown section "' . $section . '"'; - } - - $section_text[$section] = ''; - $secfile = $section == 'FILE' || $section == 'FILEEOF' || $section == 'FILE_EXTERNAL'; - $secdone = false; - continue; - } - - // Add to the section text. - if (!$secdone) { - $section_text[$section] .= $line; - } - - // End of actual test? - if ($secfile && preg_match('/^===DONE===\s*$/', $line)) { - $secdone = true; - } - } - - // the redirect section allows a set of tests to be reused outside of - // a given test dir - if ($bork_info === null) { - if (isset($section_text['REDIRECTTEST'])) { - if ($IN_REDIRECT) { - $bork_info = "Can't redirect a test from within a redirected test"; - } - } else { - if (!isset($section_text['PHPDBG']) && isset($section_text['FILE']) + isset($section_text['FILEEOF']) + isset($section_text['FILE_EXTERNAL']) != 1) { - $bork_info = "missing section --FILE--"; - } - - if (isset($section_text['FILEEOF'])) { - $section_text['FILE'] = preg_replace("/[\r\n]+$/", '', $section_text['FILEEOF']); - unset($section_text['FILEEOF']); - } - - foreach (['FILE', 'EXPECT', 'EXPECTF', 'EXPECTREGEX'] as $prefix) { - $key = $prefix . '_EXTERNAL'; - - if (isset($section_text[$key])) { - // don't allow tests to retrieve files from anywhere but this subdirectory - $section_text[$key] = dirname($file) . '/' . trim(str_replace('..', '', $section_text[$key])); - - if (file_exists($section_text[$key])) { - $section_text[$prefix] = file_get_contents($section_text[$key]); - unset($section_text[$key]); - } else { - $bork_info = "could not load --" . $key . "-- " . dirname($file) . '/' . trim($section_text[$key]); - } - } - } - - if ((isset($section_text['EXPECT']) + isset($section_text['EXPECTF']) + isset($section_text['EXPECTREGEX'])) != 1) { - $bork_info = "missing section --EXPECT--, --EXPECTF-- or --EXPECTREGEX--"; - } - } - } - fclose($fp); - $shortname = str_replace(TEST_PHP_SRCDIR . '/', '', $file); $tested_file = $shortname; - if ($bork_info !== null) { - show_result("BORK", $bork_info, $tested_file); + try { + $test = new TestFile($file, (bool)$IN_REDIRECT); + } catch (BorkageException $ex) { + show_result("BORK", $ex->getMessage(), $tested_file); $PHP_FAILED_TESTS['BORKED'][] = [ 'name' => $file, 'test_name' => '', 'output' => '', 'diff' => '', - 'info' => "$bork_info [$file]", + 'info' => "{$ex->getMessage()} [$file]", ]; - junit_mark_test_as('BORK', $shortname, $tested_file, 0, $bork_info); + $junit->markTestAs('BORK', $shortname, $tested_file, 0, $ex->getMessage()); return 'BORKED'; } - if (isset($section_text['CAPTURE_STDIO'])) { - $captureStdIn = stripos($section_text['CAPTURE_STDIO'], 'STDIN') !== false; - $captureStdOut = stripos($section_text['CAPTURE_STDIO'], 'STDOUT') !== false; - $captureStdErr = stripos($section_text['CAPTURE_STDIO'], 'STDERR') !== false; + $tested = $test->getName(); + + if ($test->hasSection('FILE_EXTERNAL')) { + if ($num_repeats > 1) { + return skip_test($tested, $tested_file, $shortname, 'Test with FILE_EXTERNAL might not be repeatable'); + } + } + + if ($test->hasSection('CAPTURE_STDIO')) { + $capture = $test->getSection('CAPTURE_STDIO'); + $captureStdIn = stripos($capture, 'STDIN') !== false; + $captureStdOut = stripos($capture, 'STDOUT') !== false; + $captureStdErr = stripos($capture, 'STDERR') !== false; } else { $captureStdIn = true; $captureStdOut = true; @@ -1995,55 +1920,44 @@ function run_test(string $php, $file, array $env): string $cmdRedirect = ''; } - $tested = trim($section_text['TEST']); - /* For GET/POST/PUT tests, check if cgi sapi is available and if it is, use it. */ - if (array_key_exists('CGI', $section_text) || !empty($section_text['GET']) || !empty($section_text['POST']) || !empty($section_text['GZIP_POST']) || !empty($section_text['DEFLATE_POST']) || !empty($section_text['POST_RAW']) || !empty($section_text['PUT']) || !empty($section_text['COOKIE']) || !empty($section_text['EXPECTHEADERS'])) { - if (isset($php_cgi)) { - $php = $php_cgi . ' -C '; - } elseif (IS_WINDOWS && file_exists(dirname($php) . "/php-cgi.exe")) { - $php = realpath(dirname($php) . "/php-cgi.exe") . ' -C '; - } else { - if (file_exists(dirname($php) . "/../../sapi/cgi/php-cgi")) { - $php = realpath(dirname($php) . "/../../sapi/cgi/php-cgi") . ' -C '; - } elseif (file_exists("./sapi/cgi/php-cgi")) { - $php = realpath("./sapi/cgi/php-cgi") . ' -C '; - } elseif (file_exists(dirname($php) . "/php-cgi")) { - $php = realpath(dirname($php) . "/php-cgi") . ' -C '; - } else { - show_result('SKIP', $tested, $tested_file, "reason: CGI not available"); - - junit_init_suite(junit_get_suitename_for($shortname)); - junit_mark_test_as('SKIP', $shortname, $tested, 0, 'CGI not available'); - return 'SKIPPED'; - } + if ($test->isCGI()) { + if (!$php_cgi) { + return skip_test($tested, $tested_file, $shortname, 'CGI not available'); } + $php = escapeshellarg($php_cgi) . ' -C '; $uses_cgi = true; + if ($num_repeats > 1) { + return skip_test($tested, $tested_file, $shortname, 'CGI does not support --repeat'); + } } /* For phpdbg tests, check if phpdbg sapi is available and if it is, use it. */ $extra_options = ''; - if (array_key_exists('PHPDBG', $section_text)) { - if (!isset($section_text['STDIN'])) { - $section_text['STDIN'] = $section_text['PHPDBG'] . "\n"; - } - + if ($test->hasSection('PHPDBG')) { if (isset($phpdbg)) { - $php = $phpdbg . ' -qIb'; + $php = escapeshellarg($phpdbg) . ' -qIb'; // Additional phpdbg command line options for sections that need to // be run straight away. For example, EXTENSIONS, SKIPIF, CLEAN. $extra_options = '-rr'; } else { - show_result('SKIP', $tested, $tested_file, "reason: phpdbg not available"); + return skip_test($tested, $tested_file, $shortname, 'phpdbg not available'); + } + if ($num_repeats > 1) { + return skip_test($tested, $tested_file, $shortname, 'phpdbg does not support --repeat'); + } + } - junit_init_suite(junit_get_suitename_for($shortname)); - junit_mark_test_as('SKIP', $shortname, $tested, 0, 'phpdbg not available'); - return 'SKIPPED'; + foreach (['CLEAN', 'STDIN', 'CAPTURE_STDIO'] as $section) { + if ($test->hasSection($section)) { + if ($num_repeats > 1) { + return skip_test($tested, $tested_file, $shortname, "Test with $section might not be repeatable"); + } } } - if (!$SHOW_ONLY_GROUPS && !$workerID) { + if ($show_progress && !$workerID) { show_test($test_idx, $shortname); } @@ -2085,8 +1999,8 @@ function run_test(string $php, $file, array $env): string mkdir(dirname($copy_file), 0777, true) or error("Cannot create output directory - " . dirname($copy_file)); } - if (isset($section_text['FILE'])) { - save_text($copy_file, $section_text['FILE']); + if ($test->hasSection('FILE')) { + save_text($copy_file, $test->getSection('FILE')); } $temp_filenames = [ @@ -2104,7 +2018,7 @@ function run_test(string $php, $file, array $env): string } if (is_array($IN_REDIRECT)) { - $tested = $IN_REDIRECT['prefix'] . ' ' . trim($section_text['TEST']); + $tested = $IN_REDIRECT['prefix'] . ' ' . $tested; $tested_file = $tmp_relative_file; $shortname = str_replace(TEST_PHP_SRCDIR . '/', '', $tested_file); } @@ -2135,8 +2049,9 @@ function run_test(string $php, $file, array $env): string $env['CONTENT_LENGTH'] = ''; $env['TZ'] = ''; - if (!empty($section_text['ENV'])) { - foreach (explode("\n", trim($section_text['ENV'])) as $e) { + if ($test->sectionNotEmpty('ENV')) { + $env_str = str_replace('{PWD}', dirname($file), $test->getSection('ENV')); + foreach (explode("\n", $env_str) as $e) { $e = explode('=', trim($e), 2); if (!empty($e[0]) && isset($e[1])) { @@ -2149,23 +2064,41 @@ function run_test(string $php, $file, array $env): string $ini_settings = $workerID ? ['opcache.cache_id' => "worker$workerID"] : []; // Additional required extensions - if (array_key_exists('EXTENSIONS', $section_text)) { + $extensions = []; + if ($test->hasSection('EXTENSIONS')) { + $extensions = preg_split("/[\n\r]+/", trim($test->getSection('EXTENSIONS'))); + } + if (is_array($IN_REDIRECT) && $IN_REDIRECT['EXTENSIONS'] != []) { + $extensions = array_merge($extensions, $IN_REDIRECT['EXTENSIONS']); + } + + /* Load required extensions */ + if ($extensions != []) { $ext_params = []; settings2array($ini_overwrites, $ext_params); $ext_params = settings2params($ext_params); - $ext_dir = `$php $pass_options $extra_options $ext_params $no_file_cache -d display_errors=0 -r "echo ini_get('extension_dir');"`; - $extensions = preg_split("/[\n\r]+/", trim($section_text['EXTENSIONS'])); - $loaded = explode(",", `$php $pass_options $extra_options $ext_params $no_file_cache -d display_errors=0 -r "echo implode(',', get_loaded_extensions());"`); + [$ext_dir, $loaded] = $skipCache->getExtensions("$orig_php $pass_options $extra_options $ext_params $no_file_cache"); $ext_prefix = IS_WINDOWS ? "php_" : ""; + $missing = []; foreach ($extensions as $req_ext) { - if (!in_array($req_ext, $loaded)) { - if ($req_ext == 'opcache') { - $ini_settings['zend_extension'][] = $ext_dir . DIRECTORY_SEPARATOR . $ext_prefix . $req_ext . '.' . PHP_SHLIB_SUFFIX; + if (!in_array($req_ext, $loaded, true)) { + if ($req_ext == 'opcache' || $req_ext == 'xdebug') { + $ext_file = $ext_dir . DIRECTORY_SEPARATOR . $ext_prefix . $req_ext . '.' . PHP_SHLIB_SUFFIX; + $ini_settings['zend_extension'][] = $ext_file; } else { - $ini_settings['extension'][] = $ext_dir . DIRECTORY_SEPARATOR . $ext_prefix . $req_ext . '.' . PHP_SHLIB_SUFFIX; + $ext_file = $ext_dir . DIRECTORY_SEPARATOR . $ext_prefix . $req_ext . '.' . PHP_SHLIB_SUFFIX; + $ini_settings['extension'][] = $ext_file; + } + if (!is_readable($ext_file)) { + $missing[] = $req_ext; } } } + if ($missing) { + $message = 'Required extension' . (count($missing) > 1 ? 's' : '') + . ' missing: ' . implode(', ', $missing); + return skip_test($tested, $tested_file, $shortname, $message); + } } // additional ini overwrites @@ -2185,16 +2118,38 @@ function run_test(string $php, $file, array $env): string // even though all the files are re-created. $ini_settings['opcache.validate_timestamps'] = '0'; } + } else if ($num_repeats > 1) { + // Make sure warnings still show up on the second run. + $ini_settings['opcache.record_warnings'] = '1'; } // Any special ini settings // these may overwrite the test defaults... - if (array_key_exists('INI', $section_text)) { - $section_text['INI'] = str_replace('{PWD}', dirname($file), $section_text['INI']); - $section_text['INI'] = str_replace('{TMP}', sys_get_temp_dir(), $section_text['INI']); + if ($test->hasSection('INI')) { + $ini = str_replace('{PWD}', dirname($file), $test->getSection('INI')); + $ini = str_replace('{TMP}', sys_get_temp_dir(), $ini); $replacement = IS_WINDOWS ? '"' . PHP_BINARY . ' -r \"while ($in = fgets(STDIN)) echo $in;\" > $1"' : 'tee $1 >/dev/null'; - $section_text['INI'] = preg_replace('/{MAIL:(\S+)}/', $replacement, $section_text['INI']); - settings2array(preg_split("/[\n\r]+/", $section_text['INI']), $ini_settings); + $ini = preg_replace('/{MAIL:(\S+)}/', $replacement, $ini); + $skip = false; + $ini = preg_replace_callback('/{ENV:(\S+)}/', function ($m) use (&$skip) { + $name = $m[1]; + $value = getenv($name); + if ($value === false) { + $skip = sprintf('Environment variable %s is not set', $name); + return ''; + } + return $value; + }, $ini); + if ($skip !== false) { + return skip_test($tested, $tested_file, $shortname, $skip); + } + settings2array(preg_split("/[\n\r]+/", $ini), $ini_settings); + + if (isset($ini_settings['opcache.opt_debug_level'])) { + if ($num_repeats > 1) { + return skip_test($tested, $tested_file, $shortname, 'opt_debug_level tests are not repeatable'); + } + } } $ini_settings = settings2params($ini_settings); @@ -2205,85 +2160,92 @@ function run_test(string $php, $file, array $env): string $info = ''; $warn = false; - if (array_key_exists('SKIPIF', $section_text)) { - if (trim($section_text['SKIPIF'])) { - show_file_block('skip', $section_text['SKIPIF']); - save_text($test_skipif, $section_text['SKIPIF'], $temp_skipif); - $extra = !IS_WINDOWS ? - "unset REQUEST_METHOD; unset QUERY_STRING; unset PATH_TRANSLATED; unset SCRIPT_FILENAME; unset REQUEST_METHOD;" : ""; + if ($test->sectionNotEmpty('SKIPIF')) { + show_file_block('skip', $test->getSection('SKIPIF')); + $extra = !IS_WINDOWS ? + "unset REQUEST_METHOD; unset QUERY_STRING; unset PATH_TRANSLATED; unset SCRIPT_FILENAME; unset REQUEST_METHOD;" : ""; - if ($valgrind) { - $env['USE_ZEND_ALLOC'] = '0'; - $env['ZEND_DONT_UNLOAD_MODULES'] = 1; - } + if ($valgrind) { + $env['USE_ZEND_ALLOC'] = '0'; + $env['ZEND_DONT_UNLOAD_MODULES'] = 1; + } - junit_start_timer($shortname); + $junit->startTimer($shortname); - $output = system_with_timeout("$extra $php $pass_options $extra_options -q $orig_ini_settings $no_file_cache -d display_errors=1 -d display_startup_errors=0 \"$test_skipif\"", $env); - $output = trim($output); + $startTime = microtime(true); + $commandLine = "$extra $php $pass_options $extra_options -q $orig_ini_settings $no_file_cache -d display_errors=1 -d display_startup_errors=0"; + $output = $skipCache->checkSkip($commandLine, $test->getSection('SKIPIF'), $test_skipif, $temp_skipif, $env); - junit_finish_timer($shortname); + $time = microtime(true) - $startTime; + $junit->stopTimer($shortname); - if (!$cfg['keep']['skip']) { - @unlink($test_skipif); - } + if ($time > $slow_min_ms / 1000) { + $PHP_FAILED_TESTS['SLOW'][] = [ + 'name' => $file, + 'test_name' => 'SKIPIF of ' . $tested . " [$tested_file]", + 'output' => '', + 'diff' => '', + 'info' => $time, + ]; + } - if (!strncasecmp('skip', $output, 4)) { - if (preg_match('/^skip\s*(.+)/i', $output, $m)) { - show_result('SKIP', $tested, $tested_file, "reason: $m[1]", $temp_filenames); - } else { - show_result('SKIP', $tested, $tested_file, '', $temp_filenames); - } + if (!$cfg['keep']['skip']) { + @unlink($test_skipif); + } - if (!$cfg['keep']['skip']) { - @unlink($test_skipif); - } + if (!strncasecmp('skip', $output, 4)) { + if (preg_match('/^skip\s*(.+)/i', $output, $m)) { + show_result('SKIP', $tested, $tested_file, "reason: $m[1]", $temp_filenames); + } else { + show_result('SKIP', $tested, $tested_file, '', $temp_filenames); + } - $message = !empty($m[1]) ? $m[1] : ''; - junit_mark_test_as('SKIP', $shortname, $tested, null, $message); - return 'SKIPPED'; - } - - if (!strncasecmp('info', $output, 4) && preg_match('/^info\s*(.+)/i', $output, $m)) { - $info = " (info: $m[1])"; - } elseif (!strncasecmp('warn', $output, 4) && preg_match('/^warn\s+(.+)/i', $output, $m)) { - $warn = true; /* only if there is a reason */ - $info = " (warn: $m[1])"; - } elseif (!strncasecmp('xfail', $output, 5)) { - // Pretend we have an XFAIL section - $section_text['XFAIL'] = ltrim(substr($output, 5)); - } elseif ($output !== '') { - show_result("BORK", $output, $tested_file, 'reason: invalid output from SKIPIF', $temp_filenames); - $PHP_FAILED_TESTS['BORKED'][] = [ - 'name' => $file, - 'test_name' => '', - 'output' => '', - 'diff' => '', - 'info' => "$output [$file]", - ]; + $message = !empty($m[1]) ? $m[1] : ''; + $junit->markTestAs('SKIP', $shortname, $tested, null, $message); + return 'SKIPPED'; + } - junit_mark_test_as('BORK', $shortname, $tested, null, $output); - return 'BORKED'; - } + if (!strncasecmp('info', $output, 4) && preg_match('/^info\s*(.+)/i', $output, $m)) { + $info = " (info: $m[1])"; + } elseif (!strncasecmp('warn', $output, 4) && preg_match('/^warn\s+(.+)/i', $output, $m)) { + $warn = true; /* only if there is a reason */ + $info = " (warn: $m[1])"; + } elseif (!strncasecmp('xfail', $output, 5)) { + // Pretend we have an XFAIL section + $test->setSection('XFAIL', ltrim(substr($output, 5))); + } elseif (!strncasecmp('xleak', $output, 5)) { + // Pretend we have an XLEAK section + $test->setSection('XLEAK', ltrim(substr($output, 5))); + } elseif ($output !== '') { + show_result("BORK", $output, $tested_file, 'reason: invalid output from SKIPIF', $temp_filenames); + $PHP_FAILED_TESTS['BORKED'][] = [ + 'name' => $file, + 'test_name' => '', + 'output' => '', + 'diff' => '', + 'info' => "$output [$file]", + ]; + + $junit->markTestAs('BORK', $shortname, $tested, null, $output); + return 'BORKED'; } } - if (!extension_loaded("zlib") - && (array_key_exists("GZIP_POST", $section_text) - || array_key_exists("DEFLATE_POST", $section_text))) { + if (!extension_loaded("zlib") && $test->hasAnySections("GZIP_POST", "DEFLATE_POST")) { $message = "ext/zlib required"; show_result('SKIP', $tested, $tested_file, "reason: $message", $temp_filenames); - junit_mark_test_as('SKIP', $shortname, $tested, null, $message); + $junit->markTestAs('SKIP', $shortname, $tested, null, $message); return 'SKIPPED'; } - if (isset($section_text['REDIRECTTEST'])) { + if ($test->hasSection('REDIRECTTEST')) { $test_files = []; - $IN_REDIRECT = eval($section_text['REDIRECTTEST']); + $IN_REDIRECT = eval($test->getSection('REDIRECTTEST')); $IN_REDIRECT['via'] = "via [$shortname]\n\t"; $IN_REDIRECT['dir'] = realpath(dirname($file)); - $IN_REDIRECT['prefix'] = trim($section_text['TEST']); + $IN_REDIRECT['prefix'] = $tested; + $IN_REDIRECT['EXTENSIONS'] = $extensions; if (!empty($IN_REDIRECT['TESTS'])) { if (is_array($org_file)) { @@ -2313,22 +2275,22 @@ function run_test(string $php, $file, array $env): string // a redirected test never fails $IN_REDIRECT = false; - junit_mark_test_as('PASS', $shortname, $tested); + $junit->markTestAs('PASS', $shortname, $tested); return 'REDIR'; - } else { - $bork_info = "Redirect info must contain exactly one TEST string to be used as redirect directory."; - show_result("BORK", $bork_info, '', '', $temp_filenames); - $PHP_FAILED_TESTS['BORKED'][] = [ - 'name' => $file, - 'test_name' => '', - 'output' => '', - 'diff' => '', - 'info' => "$bork_info [$file]", - ]; } + + $bork_info = "Redirect info must contain exactly one TEST string to be used as redirect directory."; + show_result("BORK", $bork_info, '', '', $temp_filenames); + $PHP_FAILED_TESTS['BORKED'][] = [ + 'name' => $file, + 'test_name' => '', + 'output' => '', + 'diff' => '', + 'info' => "$bork_info [$file]", + ]; } - if (is_array($org_file) || isset($section_text['REDIRECTTEST'])) { + if (is_array($org_file) || $test->hasSection('REDIRECTTEST')) { if (is_array($org_file)) { $file = $org_file[0]; } @@ -2343,21 +2305,21 @@ function run_test(string $php, $file, array $env): string 'info' => "$bork_info [$file]", ]; - junit_mark_test_as('BORK', $shortname, $tested, null, $bork_info); + $junit->markTestAs('BORK', $shortname, $tested, null, $bork_info); return 'BORKED'; } // We've satisfied the preconditions - run the test! - if (isset($section_text['FILE'])) { - show_file_block('php', $section_text['FILE'], 'TEST'); - save_text($test_file, $section_text['FILE'], $temp_file); + if ($test->hasSection('FILE')) { + show_file_block('php', $test->getSection('FILE'), 'TEST'); + save_text($test_file, $test->getSection('FILE'), $temp_file); } else { $test_file = $temp_file = ""; } - if (array_key_exists('GET', $section_text)) { - $query_string = trim($section_text['GET']); + if ($test->hasSection('GET')) { + $query_string = trim($test->getSection('GET')); } else { $query_string = ''; } @@ -2373,13 +2335,13 @@ function run_test(string $php, $file, array $env): string $env['SCRIPT_FILENAME'] = $test_file; } - if (array_key_exists('COOKIE', $section_text)) { - $env['HTTP_COOKIE'] = trim($section_text['COOKIE']); + if ($test->hasSection('COOKIE')) { + $env['HTTP_COOKIE'] = trim($test->getSection('COOKIE')); } else { $env['HTTP_COOKIE'] = ''; } - $args = isset($section_text['ARGS']) ? ' -- ' . $section_text['ARGS'] : ''; + $args = $test->hasSection('ARGS') ? ' -- ' . $test->getSection('ARGS') : ''; if ($preload && !empty($test_file)) { save_text($preload_filename, "sectionNotEmpty('POST_RAW')) { + $post = trim($test->getSection('POST_RAW')); $raw_lines = explode("\n", $post); $request = ''; @@ -2414,14 +2376,14 @@ function run_test(string $php, $file, array $env): string $env['REQUEST_METHOD'] = 'POST'; if (empty($request)) { - junit_mark_test_as('BORK', $shortname, $tested, null, 'empty $request'); + $junit->markTestAs('BORK', $shortname, $tested, null, 'empty $request'); return 'BORKED'; } save_text($tmp_post, $request); $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\""; - } elseif (array_key_exists('PUT', $section_text) && !empty($section_text['PUT'])) { - $post = trim($section_text['PUT']); + } elseif ($test->sectionNotEmpty('PUT')) { + $post = trim($test->getSection('PUT')); $raw_lines = explode("\n", $post); $request = ''; @@ -2445,14 +2407,14 @@ function run_test(string $php, $file, array $env): string $env['REQUEST_METHOD'] = 'PUT'; if (empty($request)) { - junit_mark_test_as('BORK', $shortname, $tested, null, 'empty $request'); + $junit->markTestAs('BORK', $shortname, $tested, null, 'empty $request'); return 'BORKED'; } save_text($tmp_post, $request); $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\""; - } elseif (array_key_exists('POST', $section_text) && !empty($section_text['POST'])) { - $post = trim($section_text['POST']); + } elseif ($test->sectionNotEmpty('POST')) { + $post = trim($test->getSection('POST')); $content_length = strlen($post); save_text($tmp_post, $post); @@ -2466,8 +2428,8 @@ function run_test(string $php, $file, array $env): string } $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\""; - } elseif (array_key_exists('GZIP_POST', $section_text) && !empty($section_text['GZIP_POST'])) { - $post = trim($section_text['GZIP_POST']); + } elseif ($test->sectionNotEmpty('GZIP_POST')) { + $post = trim($test->getSection('GZIP_POST')); $post = gzencode($post, 9, FORCE_GZIP); $env['HTTP_CONTENT_ENCODING'] = 'gzip'; @@ -2479,8 +2441,8 @@ function run_test(string $php, $file, array $env): string $env['CONTENT_LENGTH'] = $content_length; $cmd = "$php $pass_options $ini_settings -f \"$test_file\"$cmdRedirect < \"$tmp_post\""; - } elseif (array_key_exists('DEFLATE_POST', $section_text) && !empty($section_text['DEFLATE_POST'])) { - $post = trim($section_text['DEFLATE_POST']); + } elseif ($test->sectionNotEmpty('DEFLATE_POST')) { + $post = trim($test->getSection('DEFLATE_POST')); $post = gzcompress($post, 9); $env['HTTP_CONTENT_ENCODING'] = 'deflate'; save_text($tmp_post, $post); @@ -2496,9 +2458,11 @@ function run_test(string $php, $file, array $env): string $env['CONTENT_TYPE'] = ''; $env['CONTENT_LENGTH'] = ''; - $cmd = "$php $pass_options $ini_settings -f \"$test_file\" $args$cmdRedirect"; + $repeat_option = $num_repeats > 1 ? "--repeat $num_repeats" : ""; + $cmd = "$php $pass_options $repeat_option $ini_settings -f \"$test_file\" $args$cmdRedirect"; } + $orig_cmd = $cmd; if ($valgrind) { $env['USE_ZEND_ALLOC'] = '0'; $env['ZEND_DONT_UNLOAD_MODULES'] = 1; @@ -2506,6 +2470,16 @@ function run_test(string $php, $file, array $env): string $cmd = $valgrind->wrapCommand($cmd, $memcheck_filename, strpos($test_file, "pcre") !== false); } + if ($test->hasSection('XLEAK')) { + $env['ZEND_ALLOC_PRINT_LEAKS'] = '0'; + if (isset($env['SKIP_ASAN'])) { + // $env['LSAN_OPTIONS'] = 'detect_leaks=0'; + /* For unknown reasons, LSAN_OPTIONS=detect_leaks=0 would occasionally not be picked up + * in CI. Skip the test with ASAN, as it's not worth investegating. */ + return skip_test($tested, $tested_file, $shortname, 'xleak does not work with asan'); + } + } + if ($DETAILED) { echo " CONTENT_LENGTH = " . $env['CONTENT_LENGTH'] . " @@ -2520,44 +2494,43 @@ function run_test(string $php, $file, array $env): string "; } - junit_start_timer($shortname); + $junit->startTimer($shortname); $hrtime = hrtime(); $startTime = $hrtime[0] * 1000000000 + $hrtime[1]; - $out = system_with_timeout($cmd, $env, $section_text['STDIN'] ?? null, $captureStdIn, $captureStdOut, $captureStdErr); + $stdin = $test->hasSection('STDIN') ? $test->getSection('STDIN') : null; + $out = system_with_timeout($cmd, $env, $stdin, $captureStdIn, $captureStdOut, $captureStdErr); - junit_finish_timer($shortname); + $junit->stopTimer($shortname); $hrtime = hrtime(); $time = $hrtime[0] * 1000000000 + $hrtime[1] - $startTime; if ($time >= $slow_min_ms * 1000000) { $PHP_FAILED_TESTS['SLOW'][] = [ 'name' => $file, - 'test_name' => (is_array($IN_REDIRECT) ? $IN_REDIRECT['via'] : '') . $tested . " [$tested_file]", + 'test_name' => $tested . " [$tested_file]", 'output' => '', 'diff' => '', 'info' => $time / 1000000000, ]; } - if (array_key_exists('CLEAN', $section_text) && (!$no_clean || $cfg['keep']['clean'])) { - if (trim($section_text['CLEAN'])) { - show_file_block('clean', $section_text['CLEAN']); - save_text($test_clean, trim($section_text['CLEAN']), $temp_clean); + // Remember CLEAN output to report borked test if it otherwise passes. + $clean_output = null; + if ((!$no_clean || $cfg['keep']['clean']) && $test->sectionNotEmpty('CLEAN')) { + show_file_block('clean', $test->getSection('CLEAN')); + save_text($test_clean, trim($test->getSection('CLEAN')), $temp_clean); - if (!$no_clean) { - $extra = !IS_WINDOWS ? - "unset REQUEST_METHOD; unset QUERY_STRING; unset PATH_TRANSLATED; unset SCRIPT_FILENAME; unset REQUEST_METHOD;" : ""; - system_with_timeout("$extra $php $pass_options $extra_options -q $orig_ini_settings $no_file_cache \"$test_clean\"", $env); - } + if (!$no_clean) { + $extra = !IS_WINDOWS ? + "unset REQUEST_METHOD; unset QUERY_STRING; unset PATH_TRANSLATED; unset SCRIPT_FILENAME; unset REQUEST_METHOD;" : ""; + $clean_output = system_with_timeout("$extra $orig_php $pass_options -q $orig_ini_settings $no_file_cache \"$test_clean\"", $env); + } - if (!$cfg['keep']['clean']) { - @unlink($test_clean); - } + if (!$cfg['keep']['clean']) { + @unlink($test_clean); } } - @unlink($preload_filename); - $leaked = false; $passed = false; @@ -2569,6 +2542,25 @@ function run_test(string $php, $file, array $env): string } } + if ($num_repeats > 1) { + // In repeat mode, retain the output before the first execution, + // and of the last execution. Do this early, because the trimming below + // makes the newline handling complicated. + $separator1 = "Executing for the first time...\n"; + $separator1_pos = strpos($out, $separator1); + if ($separator1_pos !== false) { + $separator2 = "Finished execution, repeating...\n"; + $separator2_pos = strrpos($out, $separator2); + if ($separator2_pos !== false) { + $out = substr($out, 0, $separator1_pos) + . substr($out, $separator2_pos + strlen($separator2)); + } else { + $out = substr($out, 0, $separator1_pos) + . substr($out, $separator1_pos + strlen($separator1)); + } + } + } + // Does the output match what is expected? $output = preg_replace("/\r\n/", "\n", trim($out)); @@ -2587,12 +2579,14 @@ function run_test(string $php, $file, array $env): string } } + $wanted_headers = null; + $output_headers = null; $failed_headers = false; - if (isset($section_text['EXPECTHEADERS'])) { + if ($test->hasSection('EXPECTHEADERS')) { $want = []; $wanted_headers = []; - $lines = preg_split("/[\n\r]+/", $section_text['EXPECTHEADERS']); + $lines = preg_split("/[\n\r]+/", $test->getSection('EXPECTHEADERS')); foreach ($lines as $line) { if (strpos($line, ':') !== false) { @@ -2626,111 +2620,79 @@ function run_test(string $php, $file, array $env): string $output = trim(preg_replace("/\n?Warning: Can't preload [^\n]*\n?/", "", $output)); } - if (isset($section_text['EXPECTF']) || isset($section_text['EXPECTREGEX'])) { - if (isset($section_text['EXPECTF'])) { - $wanted = trim($section_text['EXPECTF']); + if ($test->hasAnySections('EXPECTF', 'EXPECTREGEX')) { + if ($test->hasSection('EXPECTF')) { + $wanted = trim($test->getSection('EXPECTF')); } else { - $wanted = trim($section_text['EXPECTREGEX']); + $wanted = trim($test->getSection('EXPECTREGEX')); } show_file_block('exp', $wanted); $wanted_re = preg_replace('/\r\n/', "\n", $wanted); - if (isset($section_text['EXPECTF'])) { - // do preg_quote, but miss out any %r delimited sections - $temp = ""; - $r = "%r"; - $startOffset = 0; - $length = strlen($wanted_re); - while ($startOffset < $length) { - $start = strpos($wanted_re, $r, $startOffset); - if ($start !== false) { - // we have found a start tag - $end = strpos($wanted_re, $r, $start + 2); - if ($end === false) { - // unbalanced tag, ignore it. - $end = $start = $length; - } - } else { - // no more %r sections - $start = $end = $length; - } - // quote a non re portion of the string - $temp .= preg_quote(substr($wanted_re, $startOffset, $start - $startOffset), '/'); - // add the re unquoted. - if ($end > $start) { - $temp .= '(' . substr($wanted_re, $start + 2, $end - $start - 2) . ')'; - } - $startOffset = $end + 2; - } - $wanted_re = $temp; - - // Stick to basics - $wanted_re = str_replace('%e', '\\' . DIRECTORY_SEPARATOR, $wanted_re); - $wanted_re = str_replace('%s', '[^\r\n]+', $wanted_re); - $wanted_re = str_replace('%S', '[^\r\n]*', $wanted_re); - $wanted_re = str_replace('%a', '.+', $wanted_re); - $wanted_re = str_replace('%A', '.*', $wanted_re); - $wanted_re = str_replace('%w', '\s*', $wanted_re); - $wanted_re = str_replace('%i', '[+-]?\d+', $wanted_re); - $wanted_re = str_replace('%d', '\d+', $wanted_re); - $wanted_re = str_replace('%x', '[0-9a-fA-F]+', $wanted_re); - $wanted_re = str_replace('%f', '[+-]?\.?\d+\.?\d*(?:[Ee][+-]?\d+)?', $wanted_re); - $wanted_re = str_replace('%c', '.', $wanted_re); - // %f allows two points "-.0.0" but that is the best *simple* expression - } - - if (preg_match("/^$wanted_re\$/s", $output)) { + if ($test->hasSection('EXPECTF')) { + $wanted_re = expectf_to_regex($wanted_re); + } + + if (preg_match('/^' . $wanted_re . '$/s', $output)) { $passed = true; - if (!$cfg['keep']['php']) { - @unlink($test_file); - } - @unlink($tmp_post); - - if (!$leaked && !$failed_headers) { - if (isset($section_text['XFAIL'])) { - $warn = true; - $info = " (warn: XFAIL section but test passes)"; - } elseif (isset($section_text['XLEAK'])) { - $warn = true; - $info = " (warn: XLEAK section but test passes)"; - } else { - show_result("PASS", $tested, $tested_file, '', $temp_filenames); - junit_mark_test_as('PASS', $shortname, $tested); - return 'PASSED'; - } - } } } else { - $wanted = trim($section_text['EXPECT']); + $wanted = trim($test->getSection('EXPECT')); $wanted = preg_replace('/\r\n/', "\n", $wanted); show_file_block('exp', $wanted); // compare and leave on success if (!strcmp($output, $wanted)) { $passed = true; + } + + $wanted_re = null; + } + if (!$passed && !$retried && error_may_be_retried($test, $output)) { + $retried = true; + goto retry; + } + + if ($passed) { + if (!$cfg['keep']['php'] && !$leaked) { + @unlink($test_file); + @unlink($preload_filename); + } + @unlink($tmp_post); - if (!$cfg['keep']['php']) { - @unlink($test_file); + if (!$leaked && !$failed_headers) { + // If the test passed and CLEAN produced output, report test as borked. + if ($clean_output) { + show_result("BORK", $output, $tested_file, 'reason: invalid output from CLEAN', $temp_filenames); + $PHP_FAILED_TESTS['BORKED'][] = [ + 'name' => $file, + 'test_name' => '', + 'output' => '', + 'diff' => '', + 'info' => "$clean_output [$file]", + ]; + + $junit->markTestAs('BORK', $shortname, $tested, null, $clean_output); + return 'BORKED'; } - @unlink($tmp_post); - if (!$leaked && !$failed_headers) { - if (isset($section_text['XFAIL'])) { - $warn = true; - $info = " (warn: XFAIL section but test passes)"; - } elseif (isset($section_text['XLEAK'])) { - $warn = true; - $info = " (warn: XLEAK section but test passes)"; - } else { - show_result("PASS", $tested, $tested_file, '', $temp_filenames); - junit_mark_test_as('PASS', $shortname, $tested); - return 'PASSED'; - } + if ($test->hasSection('XFAIL')) { + $warn = true; + $info = " (warn: XFAIL section but test passes)"; + } elseif ($test->hasSection('XLEAK') && $valgrind) { + // XLEAK with ASAN completely disables LSAN so the test is expected to pass + $warn = true; + $info = " (warn: XLEAK section but test passes)"; + } elseif ($retried) { + $warn = true; + $info = " (warn: Test passed on retry attempt)"; + } else { + show_result("PASS", $tested, $tested_file, '', $temp_filenames); + $junit->markTestAs('PASS', $shortname, $tested); + return 'PASSED'; } } - - $wanted_re = null; } // Test failed so we need to report details. @@ -2744,8 +2706,10 @@ function run_test(string $php, $file, array $env): string } } + $restype = []; + if ($leaked) { - $restype[] = isset($section_text['XLEAK']) ? + $restype[] = $test->hasSection('XLEAK') ? 'XLEAK' : 'LEAK'; } @@ -2754,12 +2718,13 @@ function run_test(string $php, $file, array $env): string } if (!$passed) { - if (isset($section_text['XFAIL'])) { + if ($test->hasSection('XFAIL')) { $restype[] = 'XFAIL'; - $info = ' XFAIL REASON: ' . rtrim($section_text['XFAIL']); - } elseif (isset($section_text['XLEAK'])) { + $info = ' XFAIL REASON: ' . rtrim($test->getSection('XFAIL')); + } elseif ($test->hasSection('XLEAK') && $valgrind) { + // XLEAK with ASAN completely disables LSAN so the test is expected to pass $restype[] = 'XLEAK'; - $info = ' XLEAK REASON: ' . rtrim($section_text['XLEAK']); + $info = ' XLEAK REASON: ' . rtrim($test->getSection('XLEAK')); } else { $restype[] = 'FAIL'; } @@ -2777,16 +2742,37 @@ function run_test(string $php, $file, array $env): string } // write .diff - $diff = generate_diff($wanted, $wanted_re, $output); + if (!empty($environment['TEST_PHP_DIFF_CMD'])) { + $diff = generate_diff_external($environment['TEST_PHP_DIFF_CMD'], $exp_filename, $output_filename); + } else { + $diff = generate_diff($wanted, $wanted_re, $output); + } + if (is_array($IN_REDIRECT)) { $orig_shortname = str_replace(TEST_PHP_SRCDIR . '/', '', $file); $diff = "# original source file: $orig_shortname\n" . $diff; } - show_file_block('diff', $diff); + if (!$SHOW_ONLY_GROUPS || array_intersect($restype, $SHOW_ONLY_GROUPS)) { + show_file_block('diff', $diff); + } if (strpos($log_format, 'D') !== false && file_put_contents($diff_filename, $diff) === false) { error("Cannot create test diff - $diff_filename"); } + // write .log + if (strpos($log_format, 'L') !== false && file_put_contents($log_filename, " +---- EXPECTED OUTPUT +$wanted +---- ACTUAL OUTPUT +$output +---- FAILED +") === false) { + error("Cannot create test log - $log_filename"); + error_report($file, $log_filename, $tested); + } + } + + if (!$passed || $leaked) { // write .sh if (strpos($log_format, 'S') !== false) { $env_lines = []; @@ -2799,16 +2785,19 @@ function run_test(string $php, $file, array $env): string {$exported_environment} case "$1" in "gdb") - gdb --args {$cmd} + gdb --args {$orig_cmd} + ;; +"lldb") + lldb -- {$orig_cmd} ;; "valgrind") - USE_ZEND_ALLOC=0 valgrind $2 {$cmd} + USE_ZEND_ALLOC=0 valgrind $2 {$orig_cmd} ;; "rr") - rr record $2 {$cmd} + rr record $2 {$orig_cmd} ;; *) - {$cmd} + {$orig_cmd} ;; esac SH; @@ -2817,18 +2806,6 @@ function run_test(string $php, $file, array $env): string } chmod($sh_filename, 0755); } - - // write .log - if (strpos($log_format, 'L') !== false && file_put_contents($log_filename, " ----- EXPECTED OUTPUT -$wanted ----- ACTUAL OUTPUT -$output ----- FAILED -") === false) { - error("Cannot create test log - $log_filename"); - error_report($file, $log_filename, $tested); - } } if ($valgrind && $leaked && $cfg["show"]["mem"]) { @@ -2849,198 +2826,150 @@ function run_test(string $php, $file, array $env): string $diff = empty($diff) ? '' : preg_replace('/\e/', '', $diff); - junit_mark_test_as($restype, $shortname, $tested, null, $info, $diff); + $junit->markTestAs($restype, $shortname, $tested, null, $info, $diff); return $restype[0] . 'ED'; } -/** - * @return bool|int - */ -function comp_line(string $l1, string $l2, bool $is_reg) +function is_flaky(TestFile $test): bool { - if ($is_reg) { - return preg_match('/^' . $l1 . '$/s', $l2); - } else { - return !strcmp($l1, $l2); + if ($test->hasSection('FLAKY')) { + return true; + } + if (!$test->hasSection('FILE')) { + return false; } + $file = $test->getSection('FILE'); + $flaky_functions = [ + 'disk_free_space', + 'hrtime', + 'microtime', + 'sleep', + 'usleep', + ]; + $regex = '(\b(' . implode('|', $flaky_functions) . ')\()i'; + return preg_match($regex, $file) === 1; } -function count_array_diff( - array $ar1, - array $ar2, - bool $is_reg, - array $w, - int $idx1, - int $idx2, - int $cnt1, - int $cnt2, - int $steps -): int { - $equal = 0; - - while ($idx1 < $cnt1 && $idx2 < $cnt2 && comp_line($ar1[$idx1], $ar2[$idx2], $is_reg)) { - $idx1++; - $idx2++; - $equal++; - $steps--; - } - if (--$steps > 0) { - $eq1 = 0; - $st = $steps / 2; +function is_flaky_output(string $output): bool +{ + $messages = [ + '404: page not found', + 'address already in use', + 'connection refused', + 'deadlock', + 'mailbox already exists', + 'timed out', + ]; + $regex = '(\b(' . implode('|', $messages) . ')\b)i'; + return preg_match($regex, $output) === 1; +} - for ($ofs1 = $idx1 + 1; $ofs1 < $cnt1 && $st-- > 0; $ofs1++) { - $eq = @count_array_diff($ar1, $ar2, $is_reg, $w, $ofs1, $idx2, $cnt1, $cnt2, $st); +function error_may_be_retried(TestFile $test, string $output): bool +{ + return is_flaky_output($output) + || is_flaky($test); +} - if ($eq > $eq1) { - $eq1 = $eq; +function expectf_to_regex(?string $wanted): string +{ + $wanted_re = $wanted ?? ''; + + $wanted_re = preg_replace('/\r\n/', "\n", $wanted_re); + + // do preg_quote, but miss out any %r delimited sections + $temp = ""; + $r = "%r"; + $startOffset = 0; + $length = strlen($wanted_re); + while ($startOffset < $length) { + $start = strpos($wanted_re, $r, $startOffset); + if ($start !== false) { + // we have found a start tag + $end = strpos($wanted_re, $r, $start + 2); + if ($end === false) { + // unbalanced tag, ignore it. + $end = $start = $length; } + } else { + // no more %r sections + $start = $end = $length; } - - $eq2 = 0; - $st = $steps; - - for ($ofs2 = $idx2 + 1; $ofs2 < $cnt2 && $st-- > 0; $ofs2++) { - $eq = @count_array_diff($ar1, $ar2, $is_reg, $w, $idx1, $ofs2, $cnt1, $cnt2, $st); - if ($eq > $eq2) { - $eq2 = $eq; - } + // quote a non re portion of the string + $temp .= preg_quote(substr($wanted_re, $startOffset, $start - $startOffset), '/'); + // add the re unquoted. + if ($end > $start) { + $temp .= '(' . substr($wanted_re, $start + 2, $end - $start - 2) . ')'; } + $startOffset = $end + 2; + } + $wanted_re = $temp; + + return strtr($wanted_re, [ + '%e' => preg_quote(DIRECTORY_SEPARATOR, '/'), + '%s' => '[^\r\n]+', + '%S' => '[^\r\n]*', + '%a' => '.+', + '%A' => '.*', + '%w' => '\s*', + '%i' => '[+-]?\d+', + '%d' => '\d+', + '%x' => '[0-9a-fA-F]+', + '%f' => '[+-]?(?:\d+|(?=\.\d))(?:\.\d+)?(?:[Ee][+-]?\d+)?', + '%c' => '.', + '%0' => '\x00', + ]); +} - if ($eq1 > $eq2) { - $equal += $eq1; - } elseif ($eq2 > 0) { - $equal += $eq2; - } +/** + * @return bool|int + */ +function comp_line(string $l1, string $l2, bool $is_reg) +{ + if ($is_reg) { + return preg_match('/^' . $l1 . '$/s', $l2); } - return $equal; + return !strcmp($l1, $l2); } -function generate_array_diff(array $ar1, array $ar2, bool $is_reg, array $w): array +/** + * Map "Zend OPcache" to "opcache" and convert all ext names to lowercase. + */ +function remap_loaded_extensions_names(array $names): array { - global $context_line_count; - $idx1 = 0; - $cnt1 = @count($ar1); - $idx2 = 0; - $cnt2 = @count($ar2); - $diff = []; - $old1 = []; - $old2 = []; - $number_len = max(3, strlen((string)max($cnt1 + 1, $cnt2 + 1))); - $line_number_spec = '%0' . $number_len . 'd'; - - /** Mapping from $idx2 to $idx1, including indexes of idx2 that are identical to idx1 as well as entries that don't have matches */ - $mapping = []; - - while ($idx1 < $cnt1 && $idx2 < $cnt2) { - $mapping[$idx2] = $idx1; - if (comp_line($ar1[$idx1], $ar2[$idx2], $is_reg)) { - $idx1++; - $idx2++; + $exts = []; + foreach ($names as $name) { + if ($name === 'Core') { continue; - } else { - $c1 = @count_array_diff($ar1, $ar2, $is_reg, $w, $idx1 + 1, $idx2, $cnt1, $cnt2, 10); - $c2 = @count_array_diff($ar1, $ar2, $is_reg, $w, $idx1, $idx2 + 1, $cnt1, $cnt2, 10); - - if ($c1 > $c2) { - $old1[$idx1] = sprintf("{$line_number_spec}- ", $idx1 + 1) . $w[$idx1++]; - } elseif ($c2 > 0) { - $old2[$idx2] = sprintf("{$line_number_spec}+ ", $idx2 + 1) . $ar2[$idx2++]; - } else { - $old1[$idx1] = sprintf("{$line_number_spec}- ", $idx1 + 1) . $w[$idx1++]; - $old2[$idx2] = sprintf("{$line_number_spec}+ ", $idx2 + 1) . $ar2[$idx2++]; - } - $last_printed_context_line = $idx1; - } - } - $mapping[$idx2] = $idx1; - - reset($old1); - $k1 = key($old1); - $l1 = -2; - reset($old2); - $k2 = key($old2); - $l2 = -2; - $old_k1 = -1; - $add_context_lines = function (int $new_k1) use (&$old_k1, &$diff, $w, $context_line_count, $number_len) { - if ($old_k1 >= $new_k1 || !$context_line_count) { - return; - } - $end = $new_k1 - 1; - $range_end = min($end, $old_k1 + $context_line_count); - if ($old_k1 >= 0) { - while ($old_k1 < $range_end) { - $diff[] = str_repeat(' ', $number_len + 2) . $w[$old_k1++]; - } - } - if ($end - $context_line_count > $old_k1) { - $old_k1 = $end - $context_line_count; - if ($old_k1 > 0) { - // Add a '--' to mark sections where the common areas were truncated - $diff[] = '--'; - } - } - $old_k1 = max($old_k1, 0); - while ($old_k1 < $end) { - $diff[] = str_repeat(' ', $number_len + 2) . $w[$old_k1++]; - } - $old_k1 = $new_k1; - }; - - while ($k1 !== null || $k2 !== null) { - if ($k1 == $l1 + 1 || $k2 === null) { - $add_context_lines($k1); - $l1 = $k1; - $diff[] = current($old1); - $old_k1 = $k1; - $k1 = next($old1) ? key($old1) : null; - } elseif ($k2 == $l2 + 1 || $k1 === null) { - $add_context_lines($mapping[$k2]); - $l2 = $k2; - $diff[] = current($old2); - $k2 = next($old2) ? key($old2) : null; - } elseif ($k1 < $mapping[$k2]) { - $add_context_lines($k1); - $l1 = $k1; - $diff[] = current($old1); - $k1 = next($old1) ? key($old1) : null; - } else { - $add_context_lines($mapping[$k2]); - $l2 = $k2; - $diff[] = current($old2); - $k2 = next($old2) ? key($old2) : null; } + $exts[] = ['Zend OPcache' => 'opcache'][$name] ?? strtolower($name); } - while ($idx1 < $cnt1) { - $add_context_lines($idx1 + 1); - $diff[] = sprintf("{$line_number_spec}- ", $idx1 + 1) . $w[$idx1++]; - } + return $exts; +} - while ($idx2 < $cnt2) { - if (isset($mapping[$idx2])) { - $add_context_lines($mapping[$idx2] + 1); - } - $diff[] = sprintf("{$line_number_spec}+ ", $idx2 + 1) . $ar2[$idx2++]; - } - $add_context_lines(min($old_k1 + $context_line_count + 1, $cnt1 + 1)); - if ($context_line_count && $old_k1 < $cnt1 + 1) { - // Add a '--' to mark sections where the common areas were truncated - $diff[] = '--'; - } +function generate_diff_external(string $diff_cmd, string $exp_file, string $output_file): string +{ + $retval = shell_exec("{$diff_cmd} {$exp_file} {$output_file}"); - return $diff; + return is_string($retval) ? $retval : 'Could not run external diff tool set through TEST_PHP_DIFF_CMD environment variable'; } function generate_diff(string $wanted, ?string $wanted_re, string $output): string { $w = explode("\n", $wanted); $o = explode("\n", $output); - $r = is_null($wanted_re) ? $w : explode("\n", $wanted_re); - $diff = generate_array_diff($r, $o, !is_null($wanted_re), $w); + $is_regex = $wanted_re !== null; - return implode(PHP_EOL, $diff); + $differ = new Differ(function ($expected, $new) use ($is_regex) { + if (!$is_regex) { + return $expected === $new; + } + $regex = '/^' . expectf_to_regex($expected). '$/s'; + return preg_match($regex, $new); + }); + return $differ->diff($w, $o); } function error(string $message): void @@ -3104,7 +3033,7 @@ function compute_summary(): void global $n_total, $test_results, $ignored_by_ext, $sum_results, $percent_results; $n_total = count($test_results); - $n_total += $ignored_by_ext; + $n_total += count($ignored_by_ext); $sum_results = [ 'PASSED' => 0, 'WARNED' => 0, @@ -3120,7 +3049,7 @@ function compute_summary(): void $sum_results[$v]++; } - $sum_results['SKIPPED'] += $ignored_by_ext; + $sum_results['SKIPPED'] += count($ignored_by_ext); $percent_results = []; foreach ($sum_results as $v => $n) { @@ -3152,43 +3081,43 @@ function get_summary(bool $show_ext_summary): string ===================================================================== TEST RESULT SUMMARY --------------------------------------------------------------------- -Exts skipped : ' . sprintf('%4d', $exts_skipped) . ' -Exts tested : ' . sprintf('%4d', $exts_tested) . ' +Exts skipped : ' . sprintf('%5d', count($exts_skipped)) . ($exts_skipped ? ' (' . implode(', ', $exts_skipped) . ')' : '') . ' +Exts tested : ' . sprintf('%5d', count($exts_tested)) . ' --------------------------------------------------------------------- '; } $summary .= ' -Number of tests : ' . sprintf('%4d', $n_total) . ' ' . sprintf('%8d', $x_total); +Number of tests : ' . sprintf('%5d', $n_total) . ' ' . sprintf('%8d', $x_total); if ($sum_results['BORKED']) { $summary .= ' -Tests borked : ' . sprintf('%4d (%5.1f%%)', $sum_results['BORKED'], $percent_results['BORKED']) . ' --------'; +Tests borked : ' . sprintf('%5d (%5.1f%%)', $sum_results['BORKED'], $percent_results['BORKED']) . ' --------'; } $summary .= ' -Tests skipped : ' . sprintf('%4d (%5.1f%%)', $sum_results['SKIPPED'], $percent_results['SKIPPED']) . ' -------- -Tests warned : ' . sprintf('%4d (%5.1f%%)', $sum_results['WARNED'], $percent_results['WARNED']) . ' ' . sprintf('(%5.1f%%)', $x_warned) . ' -Tests failed : ' . sprintf('%4d (%5.1f%%)', $sum_results['FAILED'], $percent_results['FAILED']) . ' ' . sprintf('(%5.1f%%)', $x_failed); +Tests skipped : ' . sprintf('%5d (%5.1f%%)', $sum_results['SKIPPED'], $percent_results['SKIPPED']) . ' -------- +Tests warned : ' . sprintf('%5d (%5.1f%%)', $sum_results['WARNED'], $percent_results['WARNED']) . ' ' . sprintf('(%5.1f%%)', $x_warned) . ' +Tests failed : ' . sprintf('%5d (%5.1f%%)', $sum_results['FAILED'], $percent_results['FAILED']) . ' ' . sprintf('(%5.1f%%)', $x_failed); if ($sum_results['XFAILED']) { $summary .= ' -Expected fail : ' . sprintf('%4d (%5.1f%%)', $sum_results['XFAILED'], $percent_results['XFAILED']) . ' ' . sprintf('(%5.1f%%)', $x_xfailed); +Expected fail : ' . sprintf('%5d (%5.1f%%)', $sum_results['XFAILED'], $percent_results['XFAILED']) . ' ' . sprintf('(%5.1f%%)', $x_xfailed); } if ($valgrind) { $summary .= ' -Tests leaked : ' . sprintf('%4d (%5.1f%%)', $sum_results['LEAKED'], $percent_results['LEAKED']) . ' ' . sprintf('(%5.1f%%)', $x_leaked); +Tests leaked : ' . sprintf('%5d (%5.1f%%)', $sum_results['LEAKED'], $percent_results['LEAKED']) . ' ' . sprintf('(%5.1f%%)', $x_leaked); if ($sum_results['XLEAKED']) { $summary .= ' -Expected leak : ' . sprintf('%4d (%5.1f%%)', $sum_results['XLEAKED'], $percent_results['XLEAKED']) . ' ' . sprintf('(%5.1f%%)', $x_xleaked); +Expected leak : ' . sprintf('%5d (%5.1f%%)', $sum_results['XLEAKED'], $percent_results['XLEAKED']) . ' ' . sprintf('(%5.1f%%)', $x_xleaked); } } $summary .= ' -Tests passed : ' . sprintf('%4d (%5.1f%%)', $sum_results['PASSED'], $percent_results['PASSED']) . ' ' . sprintf('(%5.1f%%)', $x_passed) . ' +Tests passed : ' . sprintf('%5d (%5.1f%%)', $sum_results['PASSED'], $percent_results['PASSED']) . ' ' . sprintf('(%5.1f%%)', $x_passed) . ' --------------------------------------------------------------------- -Time taken : ' . sprintf('%4d seconds', $end_time - $start_time) . ' +Time taken : ' . sprintf('%5d seconds', $end_time - $start_time) . ' ===================================================================== '; $failed_test_summary = ''; @@ -3308,22 +3237,22 @@ function show_summary(): void function show_redirect_start(string $tests, string $tested, string $tested_file): void { - global $SHOW_ONLY_GROUPS; + global $SHOW_ONLY_GROUPS, $show_progress; if (!$SHOW_ONLY_GROUPS || in_array('REDIRECT', $SHOW_ONLY_GROUPS)) { echo "REDIRECT $tests ($tested [$tested_file]) begin\n"; - } else { + } elseif ($show_progress) { clear_show_test(); } } function show_redirect_ends(string $tests, string $tested, string $tested_file): void { - global $SHOW_ONLY_GROUPS; + global $SHOW_ONLY_GROUPS, $show_progress; if (!$SHOW_ONLY_GROUPS || in_array('REDIRECT', $SHOW_ONLY_GROUPS)) { echo "REDIRECT $tests ($tested [$tested_file]) done\n"; - } else { + } elseif ($show_progress) { clear_show_test(); } } @@ -3365,7 +3294,7 @@ function show_result( string $extra = '', ?array $temp_filenames = null ): void { - global $SHOW_ONLY_GROUPS, $colorize; + global $SHOW_ONLY_GROUPS, $colorize, $show_progress; if (!$SHOW_ONLY_GROUPS || in_array($result, $SHOW_ONLY_GROUPS)) { if ($colorize) { @@ -3387,324 +3316,379 @@ function show_result( } else { echo "$result $tested [$tested_file] $extra\n"; } - } elseif (!$SHOW_ONLY_GROUPS) { + } elseif ($show_progress) { clear_show_test(); } +} +class BorkageException extends Exception +{ } -function junit_init(): void +class JUnit { - // Check whether a junit log is wanted. - global $workerID; - $JUNIT = getenv('TEST_PHP_JUNIT'); - if (empty($JUNIT)) { - $GLOBALS['JUNIT'] = false; - return; - } - if ($workerID) { - $fp = null; - } elseif (!$fp = fopen($JUNIT, 'w')) { - error("Failed to open $JUNIT for writing."); - } - $GLOBALS['JUNIT'] = [ - 'fp' => $fp, - 'name' => 'PHP', + private bool $enabled = true; + private $fp = null; + private array $suites = []; + private array $rootSuite = self::EMPTY_SUITE + ['name' => 'php']; + + private const EMPTY_SUITE = [ 'test_total' => 0, 'test_pass' => 0, 'test_fail' => 0, 'test_error' => 0, 'test_skip' => 0, 'test_warn' => 0, + 'files' => [], 'execution_time' => 0, - 'suites' => [], - 'files' => [] ]; -} -function junit_save_xml(): void -{ - global $JUNIT; - if (!junit_enabled()) { - return; + /** + * @throws Exception + */ + public function __construct(array $env, int $workerID) + { + // Check whether a junit log is wanted. + $fileName = $env['TEST_PHP_JUNIT'] ?? null; + if (empty($fileName)) { + $this->enabled = false; + return; + } + if (!$workerID && !$this->fp = fopen($fileName, 'w')) { + throw new Exception("Failed to open $fileName for writing."); + } } - $xml = '<' . '?' . 'xml version="1.0" encoding="UTF-8"' . '?' . '>' . PHP_EOL; - $xml .= sprintf( - '' . PHP_EOL, - $JUNIT['name'], - $JUNIT['test_total'], - $JUNIT['test_fail'], - $JUNIT['test_error'], - $JUNIT['test_skip'], - $JUNIT['execution_time'] - ); - $xml .= junit_get_suite_xml(); - $xml .= ''; - fwrite($JUNIT['fp'], $xml); -} + public function isEnabled(): bool + { + return $this->enabled; + } -function junit_get_suite_xml(string $suite_name = ''): string -{ - global $JUNIT; - - $result = ""; - - foreach ($JUNIT['suites'] as $suite_name => $suite) { - $result .= sprintf( - '' . PHP_EOL, - $suite['name'], - $suite['test_total'], - $suite['test_fail'], - $suite['test_error'], - $suite['test_skip'], - $suite['execution_time'] + public function clear(): void + { + $this->rootSuite = self::EMPTY_SUITE + ['name' => 'php']; + $this->suites = []; + } + + public function saveXML(): void + { + if (!$this->enabled) { + return; + } + + $xml = '<' . '?' . 'xml version="1.0" encoding="UTF-8"' . '?' . '>' . PHP_EOL; + $xml .= sprintf( + '' . PHP_EOL, + $this->rootSuite['name'], + $this->rootSuite['test_total'], + $this->rootSuite['test_fail'], + $this->rootSuite['test_error'], + $this->rootSuite['test_skip'], + $this->rootSuite['execution_time'] ); + $xml .= $this->getSuitesXML(); + $xml .= ''; + fwrite($this->fp, $xml); + } - if (!empty($suite_name)) { - foreach ($suite['files'] as $file) { - $result .= $JUNIT['files'][$file]['xml']; + private function getSuitesXML(): string + { + $result = ''; + + foreach ($this->suites as $suite_name => $suite) { + $result .= sprintf( + '' . PHP_EOL, + $suite['name'], + $suite['test_total'], + $suite['test_fail'], + $suite['test_error'], + $suite['test_skip'], + $suite['execution_time'] + ); + + if (!empty($suite_name)) { + foreach ($suite['files'] as $file) { + $result .= $this->rootSuite['files'][$file]['xml']; + } } + + $result .= '' . PHP_EOL; } - $result .= '' . PHP_EOL; + return $result; } - return $result; -} + public function markTestAs( + $type, + string $file_name, + string $test_name, + ?int $time = null, + string $message = '', + string $details = '' + ): void { + if (!$this->enabled) { + return; + } -function junit_enabled(): bool -{ - global $JUNIT; - return !empty($JUNIT); -} + $suite = $this->getSuiteName($file_name); -/** - * @param array|string $type - */ -function junit_mark_test_as( - $type, - string $file_name, - string $test_name, - ?float $time = null, - string $message = '', - string $details = '' -): void { - global $JUNIT; - if (!junit_enabled()) { - return; - } + $this->record($suite, 'test_total'); - $suite = junit_get_suitename_for($file_name); + $time = $time ?? $this->getTimer($file_name); + $this->record($suite, 'execution_time', $time); - junit_suite_record($suite, 'test_total'); + $escaped_details = htmlspecialchars($details, ENT_QUOTES, 'UTF-8'); + $escaped_details = preg_replace_callback('/[\0-\x08\x0B\x0C\x0E-\x1F]/', function ($c) { + return sprintf('[[0x%02x]]', ord($c[0])); + }, $escaped_details); + $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8'); - $time = $time ?? junit_get_timer($file_name); - junit_suite_record($suite, 'execution_time', $time); + $escaped_test_name = htmlspecialchars($file_name . ' (' . $test_name . ')', ENT_QUOTES); + $this->rootSuite['files'][$file_name]['xml'] = "\n"; - $escaped_details = htmlspecialchars($details, ENT_QUOTES, 'UTF-8'); - $escaped_details = preg_replace_callback('/[\0-\x08\x0B\x0C\x0E-\x1F]/', function (array $c): string { - return sprintf('[[0x%02x]]', ord($c[0])); - }, $escaped_details); - $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8'); + if (is_array($type)) { + $output_type = $type[0] . 'ED'; + $temp = array_intersect(['XFAIL', 'XLEAK', 'FAIL', 'WARN'], $type); + $type = reset($temp); + } else { + $output_type = $type . 'ED'; + } - $escaped_test_name = htmlspecialchars($file_name . ' (' . $test_name . ')', ENT_QUOTES); - $JUNIT['files'][$file_name]['xml'] = "\n"; + if ('PASS' == $type || 'XFAIL' == $type || 'XLEAK' == $type) { + $this->record($suite, 'test_pass'); + } elseif ('BORK' == $type) { + $this->record($suite, 'test_error'); + $this->rootSuite['files'][$file_name]['xml'] .= "\n"; + } elseif ('SKIP' == $type) { + $this->record($suite, 'test_skip'); + $this->rootSuite['files'][$file_name]['xml'] .= "$escaped_message\n"; + } elseif ('WARN' == $type) { + $this->record($suite, 'test_warn'); + $this->rootSuite['files'][$file_name]['xml'] .= "$escaped_message\n"; + } elseif ('FAIL' == $type) { + $this->record($suite, 'test_fail'); + $this->rootSuite['files'][$file_name]['xml'] .= "$escaped_details\n"; + } else { + $this->record($suite, 'test_error'); + $this->rootSuite['files'][$file_name]['xml'] .= "$escaped_details\n"; + } - if (is_array($type)) { - $output_type = $type[0] . 'ED'; - $temp = array_intersect(['XFAIL', 'XLEAK', 'FAIL', 'WARN'], $type); - $type = reset($temp); - } else { - $output_type = $type . 'ED'; - } - - if ('PASS' == $type || 'XFAIL' == $type || 'XLEAK' == $type) { - junit_suite_record($suite, 'test_pass'); - } elseif ('BORK' == $type) { - junit_suite_record($suite, 'test_error'); - $JUNIT['files'][$file_name]['xml'] .= "\n"; - } elseif ('SKIP' == $type) { - junit_suite_record($suite, 'test_skip'); - $JUNIT['files'][$file_name]['xml'] .= "$escaped_message\n"; - } elseif ('WARN' == $type) { - junit_suite_record($suite, 'test_warn'); - $JUNIT['files'][$file_name]['xml'] .= "$escaped_message\n"; - } elseif ('FAIL' == $type) { - junit_suite_record($suite, 'test_fail'); - $JUNIT['files'][$file_name]['xml'] .= "$escaped_details\n"; - } else { - junit_suite_record($suite, 'test_error'); - $JUNIT['files'][$file_name]['xml'] .= "$escaped_details\n"; + $this->rootSuite['files'][$file_name]['xml'] .= "\n"; } - $JUNIT['files'][$file_name]['xml'] .= "\n"; -} + private function record(string $suite, string $param, $value = 1): void + { + $this->rootSuite[$param] += $value; + $this->suites[$suite][$param] += $value; + } -function junit_suite_record(string $suite, string $param, float $value = 1): void -{ - global $JUNIT; + private function getTimer(string $file_name) + { + if (!$this->enabled) { + return 0; + } - $JUNIT[$param] += $value; - $JUNIT['suites'][$suite][$param] += $value; -} + if (isset($this->rootSuite['files'][$file_name]['total'])) { + return number_format($this->rootSuite['files'][$file_name]['total'], 4); + } -function junit_get_timer(string $file_name): float -{ - global $JUNIT; - if (!junit_enabled()) { return 0; } - if (isset($JUNIT['files'][$file_name]['total'])) { - return number_format($JUNIT['files'][$file_name]['total'], 4); - } + public function startTimer(string $file_name): void + { + if (!$this->enabled) { + return; + } - return 0; -} + if (!isset($this->rootSuite['files'][$file_name]['start'])) { + $this->rootSuite['files'][$file_name]['start'] = microtime(true); -function junit_start_timer(string $file_name): void -{ - global $JUNIT; - if (!junit_enabled()) { - return; + $suite = $this->getSuiteName($file_name); + $this->initSuite($suite); + $this->suites[$suite]['files'][$file_name] = $file_name; + } } - if (!isset($JUNIT['files'][$file_name]['start'])) { - $JUNIT['files'][$file_name]['start'] = microtime(true); - - $suite = junit_get_suitename_for($file_name); - junit_init_suite($suite); - $JUNIT['suites'][$suite]['files'][$file_name] = $file_name; + public function getSuiteName(string $file_name): string + { + return $this->pathToClassName(dirname($file_name)); } -} -function junit_get_suitename_for(string $file_name): string -{ - return junit_path_to_classname(dirname($file_name)); -} + private function pathToClassName(string $file_name): string + { + if (!$this->enabled) { + return ''; + } -function junit_path_to_classname(string $file_name): string -{ - global $JUNIT; + $ret = $this->rootSuite['name']; + $_tmp = []; - if (!junit_enabled()) { - return ''; + // lookup whether we're in the PHP source checkout + $max = 5; + if (is_file($file_name)) { + $dir = dirname(realpath($file_name)); + } else { + $dir = realpath($file_name); + } + do { + array_unshift($_tmp, basename($dir)); + $chk = $dir . DIRECTORY_SEPARATOR . "main" . DIRECTORY_SEPARATOR . "php_version.h"; + $dir = dirname($dir); + } while (!file_exists($chk) && --$max > 0); + if (file_exists($chk)) { + if ($max) { + array_shift($_tmp); + } + foreach ($_tmp as $p) { + $ret .= "." . preg_replace(",[^a-z0-9]+,i", ".", $p); + } + return $ret; + } + + return $this->rootSuite['name'] . '.' . str_replace([DIRECTORY_SEPARATOR, '-'], '.', $file_name); } - $ret = $JUNIT['name']; - $_tmp = []; + public function initSuite(string $suite_name): void + { + if (!$this->enabled) { + return; + } - // lookup whether we're in the PHP source checkout - $max = 5; - if (is_file($file_name)) { - $dir = dirname(realpath($file_name)); - } else { - $dir = realpath($file_name); + if (!empty($this->suites[$suite_name])) { + return; + } + + $this->suites[$suite_name] = self::EMPTY_SUITE + ['name' => $suite_name]; } - do { - array_unshift($_tmp, basename($dir)); - $chk = $dir . DIRECTORY_SEPARATOR . "main" . DIRECTORY_SEPARATOR . "php_version.h"; - $dir = dirname($dir); - } while (!file_exists($chk) && --$max > 0); - if (file_exists($chk)) { - if ($max) { - array_shift($_tmp); + + /** + * @throws Exception + */ + public function stopTimer(string $file_name): void + { + if (!$this->enabled) { + return; } - foreach ($_tmp as $p) { - $ret .= "." . preg_replace(",[^a-z0-9]+,i", ".", $p); + + if (!isset($this->rootSuite['files'][$file_name]['start'])) { + throw new Exception("Timer for $file_name was not started!"); } - return $ret; - } - return $JUNIT['name'] . '.' . str_replace([DIRECTORY_SEPARATOR, '-'], '.', $file_name); -} + if (!isset($this->rootSuite['files'][$file_name]['total'])) { + $this->rootSuite['files'][$file_name]['total'] = 0; + } -function junit_init_suite(string $suite_name): void -{ - global $JUNIT; - if (!junit_enabled()) { - return; + $start = $this->rootSuite['files'][$file_name]['start']; + $this->rootSuite['files'][$file_name]['total'] += microtime(true) - $start; + unset($this->rootSuite['files'][$file_name]['start']); } - if (!empty($JUNIT['suites'][$suite_name])) { - return; + public function mergeResults(?JUnit $other): void + { + if (!$this->enabled || !$other) { + return; + } + + $this->mergeSuites($this->rootSuite, $other->rootSuite); + foreach ($other->suites as $name => $suite) { + if (!isset($this->suites[$name])) { + $this->suites[$name] = $suite; + continue; + } + + $this->mergeSuites($this->suites[$name], $suite); + } } - $JUNIT['suites'][$suite_name] = [ - 'name' => $suite_name, - 'test_total' => 0, - 'test_pass' => 0, - 'test_fail' => 0, - 'test_error' => 0, - 'test_skip' => 0, - 'test_warn' => 0, - 'files' => [], - 'execution_time' => 0, - ]; + private function mergeSuites(array &$dest, array $source): void + { + $dest['test_total'] += $source['test_total']; + $dest['test_pass'] += $source['test_pass']; + $dest['test_fail'] += $source['test_fail']; + $dest['test_error'] += $source['test_error']; + $dest['test_skip'] += $source['test_skip']; + $dest['test_warn'] += $source['test_warn']; + $dest['execution_time'] += $source['execution_time']; + $dest['files'] += $source['files']; + } } -function junit_finish_timer(string $file_name): void +class SkipCache { - global $JUNIT; - if (!junit_enabled()) { - return; - } + private bool $enable; + private bool $keepFile; - if (!isset($JUNIT['files'][$file_name]['start'])) { - error("Timer for $file_name was not started!"); - } + private array $skips = []; + private array $extensions = []; + + private int $hits = 0; + private int $misses = 0; + private int $extHits = 0; + private int $extMisses = 0; - if (!isset($JUNIT['files'][$file_name]['total'])) { - $JUNIT['files'][$file_name]['total'] = 0; + public function __construct(bool $enable, bool $keepFile) + { + $this->enable = $enable; + $this->keepFile = $keepFile; } - $start = $JUNIT['files'][$file_name]['start']; - $JUNIT['files'][$file_name]['total'] += microtime(true) - $start; - unset($JUNIT['files'][$file_name]['start']); -} + public function checkSkip(string $php, string $code, string $checkFile, string $tempFile, array $env): string + { + // Extension tests frequently use something like $dir"; + + if (isset($this->skips[$key][$code])) { + $this->hits++; + if ($this->keepFile) { + save_text($checkFile, $code, $tempFile); + } + return $this->skips[$key][$code]; + } -function junit_merge_results(array $junit): void -{ - global $JUNIT; - $JUNIT['test_total'] += $junit['test_total']; - $JUNIT['test_pass'] += $junit['test_pass']; - $JUNIT['test_fail'] += $junit['test_fail']; - $JUNIT['test_error'] += $junit['test_error']; - $JUNIT['test_skip'] += $junit['test_skip']; - $JUNIT['test_warn'] += $junit['test_warn']; - $JUNIT['execution_time'] += $junit['execution_time']; - $JUNIT['files'] += $junit['files']; - foreach ($junit['suites'] as $name => $suite) { - if (!isset($JUNIT['suites'][$name])) { - $JUNIT['suites'][$name] = $suite; - continue; + save_text($checkFile, $code, $tempFile); + $result = trim(system_with_timeout("$php \"$checkFile\"", $env)); + if (strpos($result, 'nocache') === 0) { + $result = ''; + } else if ($this->enable) { + $this->skips[$key][$code] = $result; + } + $this->misses++; + + if (!$this->keepFile) { + @unlink($checkFile); + } + + return $result; + } + + public function getExtensions(string $php): array + { + if (isset($this->extensions[$php])) { + $this->extHits++; + return $this->extensions[$php]; } - $SUITE =& $JUNIT['suites'][$name]; - $SUITE['test_total'] += $suite['test_total']; - $SUITE['test_pass'] += $suite['test_pass']; - $SUITE['test_fail'] += $suite['test_fail']; - $SUITE['test_error'] += $suite['test_error']; - $SUITE['test_skip'] += $suite['test_skip']; - $SUITE['test_warn'] += $suite['test_warn']; - $SUITE['execution_time'] += $suite['execution_time']; - $SUITE['files'] += $suite['files']; + $extDir = shell_exec("$php -d display_errors=0 -r \"echo ini_get('extension_dir');\""); + $extensionsNames = explode(",", shell_exec("$php -d display_errors=0 -r \"echo implode(',', get_loaded_extensions());\"")); + $extensions = remap_loaded_extensions_names($extensionsNames); + + $result = [$extDir, $extensions]; + $this->extensions[$php] = $result; + $this->extMisses++; + + return $result; } } class RuntestsValgrind { - protected $version = ''; - protected $header = ''; - protected $version_3_3_0 = false; - protected $version_3_8_0 = false; - protected $tool = null; - - public function getVersion(): string - { - return $this->version; - } + protected string $version; + protected string $header; + protected bool $version_3_8_0; + protected string $tool; public function getHeader(): string { @@ -3717,7 +3701,7 @@ public function __construct(array $environment, string $tool = 'memcheck') $header = system_with_timeout("valgrind --tool={$this->tool} --version", $environment); if (!$header) { error("Valgrind returned no version info for {$this->tool}, cannot proceed.\n". - "Please check if Valgrind is installed and the tool is named correctly."); + "Please check if Valgrind is installed and the tool is named correctly."); } $count = 0; $version = preg_replace("/valgrind-(\d+)\.(\d+)\.(\d+)([.\w_-]+)?(\s+)/", '$1.$2.$3', $header, 1, $count); @@ -3727,7 +3711,6 @@ public function __construct(array $environment, string $tool = 'memcheck') $this->version = $version; $this->header = sprintf( "%s (%s)", trim($header), $this->tool); - $this->version_3_3_0 = version_compare($version, '3.3.0', '>='); $this->version_3_8_0 = version_compare($version, '3.8.0', '>='); } @@ -3740,12 +3723,208 @@ public function wrapCommand(string $cmd, string $memcheck_filename, bool $check_ /* --vex-iropt-register-updates=allregs-at-mem-access is necessary for phpdbg watchpoint tests */ if ($this->version_3_8_0) { - /* valgrind 3.3.0+ doesn't have --log-file-exactly option */ return "$vcmd --vex-iropt-register-updates=allregs-at-mem-access --log-file=$memcheck_filename $cmd"; - } elseif ($this->version_3_3_0) { - return "$vcmd --vex-iropt-precise-memory-exns=yes --log-file=$memcheck_filename $cmd"; + } + return "$vcmd --vex-iropt-precise-memory-exns=yes --log-file=$memcheck_filename $cmd"; + } +} + +class TestFile +{ + private string $fileName; + + private array $sections = ['TEST' => '']; + + private const ALLOWED_SECTIONS = [ + 'EXPECT', 'EXPECTF', 'EXPECTREGEX', 'EXPECTREGEX_EXTERNAL', 'EXPECT_EXTERNAL', 'EXPECTF_EXTERNAL', 'EXPECTHEADERS', + 'POST', 'POST_RAW', 'GZIP_POST', 'DEFLATE_POST', 'PUT', 'GET', 'COOKIE', 'ARGS', + 'FILE', 'FILEEOF', 'FILE_EXTERNAL', 'REDIRECTTEST', + 'CAPTURE_STDIO', 'STDIN', 'CGI', 'PHPDBG', + 'INI', 'ENV', 'EXTENSIONS', + 'SKIPIF', 'XFAIL', 'XLEAK', 'CLEAN', + 'CREDITS', 'DESCRIPTION', 'CONFLICTS', 'WHITESPACE_SENSITIVE', + 'FLAKY', + ]; + + /** + * @throws BorkageException + */ + public function __construct(string $fileName, bool $inRedirect) + { + $this->fileName = $fileName; + + $this->readFile(); + $this->validateAndProcess($inRedirect); + } + + public function hasSection(string $name): bool + { + return isset($this->sections[$name]); + } + + public function hasAnySections(string ...$names): bool + { + foreach ($names as $section) { + if (isset($this->sections[$section])) { + return true; + } + } + + return false; + } + + public function sectionNotEmpty(string $name): bool + { + return !empty($this->sections[$name]); + } + + /** + * @throws Exception + */ + public function getSection(string $name): string + { + if (!isset($this->sections[$name])) { + throw new Exception("Section $name not found"); + } + return $this->sections[$name]; + } + + public function getName(): string + { + return trim($this->getSection('TEST')); + } + + public function isCGI(): bool + { + return $this->hasSection('CGI') + || $this->sectionNotEmpty('GET') + || $this->sectionNotEmpty('POST') + || $this->sectionNotEmpty('GZIP_POST') + || $this->sectionNotEmpty('DEFLATE_POST') + || $this->sectionNotEmpty('POST_RAW') + || $this->sectionNotEmpty('PUT') + || $this->sectionNotEmpty('COOKIE') + || $this->sectionNotEmpty('EXPECTHEADERS'); + } + + /** + * TODO Refactor to make it not needed + */ + public function setSection(string $name, string $value): void + { + $this->sections[$name] = $value; + } + + /** + * Load the sections of the test file + * @throws BorkageException + */ + private function readFile(): void + { + $fp = fopen($this->fileName, "rb") or error("Cannot open test file: {$this->fileName}"); + + if (!feof($fp)) { + $line = fgets($fp); + + if ($line === false) { + throw new BorkageException("cannot read test"); + } } else { - return "$vcmd --vex-iropt-precise-memory-exns=yes --log-file-exactly=$memcheck_filename $cmd"; + throw new BorkageException("empty test [{$this->fileName}]"); + } + if (strncmp('--TEST--', $line, 8)) { + throw new BorkageException("tests must start with --TEST-- [{$this->fileName}]"); + } + + $section = 'TEST'; + $secfile = false; + $secdone = false; + + while (!feof($fp)) { + $line = fgets($fp); + + if ($line === false) { + break; + } + + // Match the beginning of a section. + if (preg_match('/^--([_A-Z]+)--/', $line, $r)) { + $section = (string) $r[1]; + + if (isset($this->sections[$section]) && $this->sections[$section]) { + throw new BorkageException("duplicated $section section"); + } + + // check for unknown sections + if (!in_array($section, self::ALLOWED_SECTIONS)) { + throw new BorkageException('Unknown section "' . $section . '"'); + } + + $this->sections[$section] = ''; + $secfile = $section == 'FILE' || $section == 'FILEEOF' || $section == 'FILE_EXTERNAL'; + $secdone = false; + continue; + } + + // Add to the section text. + if (!$secdone) { + $this->sections[$section] .= $line; + } + + // End of actual test? + if ($secfile && preg_match('/^===DONE===\s*$/', $line)) { + $secdone = true; + } + } + + fclose($fp); + } + + /** + * @throws BorkageException + */ + private function validateAndProcess(bool $inRedirect): void + { + // the redirect section allows a set of tests to be reused outside of + // a given test dir + if ($this->hasSection('REDIRECTTEST')) { + if ($inRedirect) { + throw new BorkageException("Can't redirect a test from within a redirected test"); + } + return; + } + if (!$this->hasSection('PHPDBG') && $this->hasSection('FILE') + $this->hasSection('FILEEOF') + $this->hasSection('FILE_EXTERNAL') != 1) { + throw new BorkageException("missing section --FILE--"); + } + + if ($this->hasSection('FILEEOF')) { + $this->sections['FILE'] = preg_replace("/[\r\n]+$/", '', $this->sections['FILEEOF']); + unset($this->sections['FILEEOF']); + } + + foreach (['FILE', 'EXPECT', 'EXPECTF', 'EXPECTREGEX'] as $prefix) { + // For grepping: FILE_EXTERNAL, EXPECT_EXTERNAL, EXPECTF_EXTERNAL, EXPECTREGEX_EXTERNAL + $key = $prefix . '_EXTERNAL'; + + if ($this->hasSection($key)) { + // don't allow tests to retrieve files from anywhere but this subdirectory + $dir = dirname($this->fileName); + $fileName = $dir . '/' . trim(str_replace('..', '', $this->getSection($key))); + + if (file_exists($fileName)) { + $this->sections[$prefix] = file_get_contents($fileName); + } else { + throw new BorkageException("could not load --" . $key . "-- " . $dir . '/' . trim($fileName)); + } + } + } + + if (($this->hasSection('EXPECT') + $this->hasSection('EXPECTF') + $this->hasSection('EXPECTREGEX')) != 1) { + throw new BorkageException("missing section --EXPECT--, --EXPECTF-- or --EXPECTREGEX--"); + } + + if ($this->hasSection('PHPDBG') && !$this->hasSection('STDIN')) { + $this->sections['STDIN'] = $this->sections['PHPDBG'] . "\n"; } } } @@ -3777,4 +3956,277 @@ function check_proc_open_function_exists(): void } } +function bless_failed_tests(array $failedTests): void +{ + if (empty($failedTests)) { + return; + } + $args = [ + PHP_BINARY, + __DIR__ . '/scripts/dev/bless_tests.php', + ]; + foreach ($failedTests as $test) { + $args[] = $test['name']; + } + proc_open($args, [], $pipes); +} + +/* + * BSD 3-Clause License + * + * Copyright (c) 2002-2023, Sebastian Bergmann + * All rights reserved. + * + * This file is part of sebastian/diff. + * https://github.com/sebastianbergmann/diff + */ + +final class Differ +{ + public const OLD = 0; + public const ADDED = 1; + public const REMOVED = 2; + private DiffOutputBuilder $outputBuilder; + private $isEqual; + + public function __construct(callable $isEqual) + { + $this->outputBuilder = new DiffOutputBuilder; + $this->isEqual = $isEqual; + } + + public function diff(array $from, array $to): string + { + $diff = $this->diffToArray($from, $to); + + return $this->outputBuilder->getDiff($diff); + } + + public function diffToArray(array $from, array $to): array + { + $fromLine = 1; + $toLine = 1; + + [$from, $to, $start, $end] = $this->getArrayDiffParted($from, $to); + + $common = $this->calculateCommonSubsequence(array_values($from), array_values($to)); + $diff = []; + + foreach ($start as $token) { + $diff[] = [$token, self::OLD]; + $fromLine++; + $toLine++; + } + + reset($from); + reset($to); + + foreach ($common as $token) { + while (!empty($from) && !($this->isEqual)(reset($from), $token)) { + $diff[] = [array_shift($from), self::REMOVED, $fromLine++]; + } + + while (!empty($to) && !($this->isEqual)($token, reset($to))) { + $diff[] = [array_shift($to), self::ADDED, $toLine++]; + } + + $diff[] = [$token, self::OLD]; + $fromLine++; + $toLine++; + + array_shift($from); + array_shift($to); + } + + while (($token = array_shift($from)) !== null) { + $diff[] = [$token, self::REMOVED, $fromLine++]; + } + + while (($token = array_shift($to)) !== null) { + $diff[] = [$token, self::ADDED, $toLine++]; + } + + foreach ($end as $token) { + $diff[] = [$token, self::OLD]; + } + + return $diff; + } + + private function getArrayDiffParted(array &$from, array &$to): array + { + $start = []; + $end = []; + + reset($to); + + foreach ($from as $k => $v) { + $toK = key($to); + + if (($this->isEqual)($toK, $k) && ($this->isEqual)($v, $to[$k])) { + $start[$k] = $v; + + unset($from[$k], $to[$k]); + } else { + break; + } + } + + end($from); + end($to); + + do { + $fromK = key($from); + $toK = key($to); + + if (null === $fromK || null === $toK || !($this->isEqual)(current($from), current($to))) { + break; + } + + prev($from); + prev($to); + + $end = [$fromK => $from[$fromK]] + $end; + unset($from[$fromK], $to[$toK]); + } while (true); + + return [$from, $to, $start, $end]; + } + + public function calculateCommonSubsequence(array $from, array $to): array + { + $cFrom = count($from); + $cTo = count($to); + + if ($cFrom === 0) { + return []; + } + + if ($cFrom === 1) { + foreach ($to as $toV) { + if (($this->isEqual)($from[0], $toV)) { + return [$from[0]]; + } + } + + return []; + } + + $i = (int) ($cFrom / 2); + $fromStart = array_slice($from, 0, $i); + $fromEnd = array_slice($from, $i); + $llB = $this->commonSubsequenceLength($fromStart, $to); + $llE = $this->commonSubsequenceLength(array_reverse($fromEnd), array_reverse($to)); + $jMax = 0; + $max = 0; + + for ($j = 0; $j <= $cTo; $j++) { + $m = $llB[$j] + $llE[$cTo - $j]; + + if ($m >= $max) { + $max = $m; + $jMax = $j; + } + } + + $toStart = array_slice($to, 0, $jMax); + $toEnd = array_slice($to, $jMax); + + return array_merge( + $this->calculateCommonSubsequence($fromStart, $toStart), + $this->calculateCommonSubsequence($fromEnd, $toEnd) + ); + } + + private function commonSubsequenceLength(array $from, array $to): array + { + $current = array_fill(0, count($to) + 1, 0); + $cFrom = count($from); + $cTo = count($to); + + for ($i = 0; $i < $cFrom; $i++) { + $prev = $current; + + for ($j = 0; $j < $cTo; $j++) { + if (($this->isEqual)($from[$i], $to[$j])) { + $current[$j + 1] = $prev[$j] + 1; + } else { + $current[$j + 1] = max($current[$j], $prev[$j + 1]); + } + } + } + + return $current; + } +} + +class DiffOutputBuilder +{ + public function getDiff(array $diffs): string + { + global $context_line_count; + $i = 0; + $number_len = max(3, strlen((string)count($diffs))); + $line_number_spec = '%0' . $number_len . 'd'; + $buffer = fopen('php://memory', 'r+b'); + while ($i < count($diffs)) { + // Find next difference + $next = $i; + while ($next < count($diffs)) { + if ($diffs[$next][1] !== Differ::OLD) { + break; + } + $next++; + } + // Found no more differentiating rows, we're done + if ($next === count($diffs)) { + if (($i - 1) < count($diffs)) { + fwrite($buffer, "--\n"); + } + break; + } + // Print separator if necessary + if ($i < ($next - $context_line_count)) { + fwrite($buffer, "--\n"); + $i = $next - $context_line_count; + } + // Print leading context + while ($i < $next) { + fwrite($buffer, str_repeat(' ', $number_len + 2)); + fwrite($buffer, $diffs[$i][0]); + fwrite($buffer, "\n"); + $i++; + } + // Print differences + while ($i < count($diffs) && $diffs[$i][1] !== Differ::OLD) { + fwrite($buffer, sprintf($line_number_spec, $diffs[$i][2])); + switch ($diffs[$i][1]) { + case Differ::ADDED: + fwrite($buffer, '+ '); + break; + case Differ::REMOVED: + fwrite($buffer, '- '); + break; + } + fwrite($buffer, $diffs[$i][0]); + fwrite($buffer, "\n"); + $i++; + } + // Print trailing context + $afterContext = min($i + $context_line_count, count($diffs)); + while ($i < $afterContext && $diffs[$i][1] === Differ::OLD) { + fwrite($buffer, str_repeat(' ', $number_len + 2)); + fwrite($buffer, $diffs[$i][0]); + fwrite($buffer, "\n"); + $i++; + } + } + + $diff = stream_get_contents($buffer, -1, 0); + fclose($buffer); + + return $diff; + } +} + main();