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