xref: /PHP-7.3/sapi/fpm/tests/tester.inc (revision ab061f95)
1<?php
2
3namespace FPM;
4
5use Adoy\FastCGI\Client;
6
7require_once 'fcgi.inc';
8require_once 'logtool.inc';
9require_once 'response.inc';
10
11class Tester
12{
13    /**
14     * Config directory for included files.
15     */
16    const CONF_DIR = __DIR__ . '/conf.d';
17
18    /**
19     * File extension for access log.
20     */
21    const FILE_EXT_LOG_ACC = 'acc.log';
22
23    /**
24     * File extension for error log.
25     */
26    const FILE_EXT_LOG_ERR = 'err.log';
27
28    /**
29     * File extension for slow log.
30     */
31    const FILE_EXT_LOG_SLOW = 'slow.log';
32
33    /**
34     * File extension for PID file.
35     */
36    const FILE_EXT_PID = 'pid';
37
38    /**
39     * @var array
40     */
41    static private $supportedFiles = [
42        self::FILE_EXT_LOG_ACC,
43        self::FILE_EXT_LOG_ERR,
44        self::FILE_EXT_LOG_SLOW,
45        self::FILE_EXT_PID,
46        'src.php',
47        'ini',
48        'skip.ini',
49        '*.sock',
50    ];
51
52    /**
53     * @var array
54     */
55    static private $filesToClean = ['.user.ini'];
56
57    /**
58     * @var bool
59     */
60    private $debug;
61
62    /**
63     * @var array
64     */
65    private $clients;
66
67    /**
68     * @var LogTool
69     */
70    private $logTool;
71
72    /**
73     * Configuration template
74     *
75     * @var string
76     */
77    private $configTemplate;
78
79    /**
80     * The PHP code to execute
81     *
82     * @var string
83     */
84    private $code;
85
86    /**
87     * @var array
88     */
89    private $options;
90
91    /**
92     * @var string
93     */
94    private $fileName;
95
96    /**
97     * @var resource
98     */
99    private $masterProcess;
100
101    /**
102     * @var resource
103     */
104    private $outDesc;
105
106    /**
107     * @var array
108     */
109    private $ports = [];
110
111    /**
112     * @var string
113     */
114    private $error;
115
116    /**
117     * The last response for the request call
118     *
119     * @var Response
120     */
121    private $response;
122
123    /**
124     * Clean all the created files up
125     *
126     * @param int $backTraceIndex
127     */
128    static public function clean($backTraceIndex = 1)
129    {
130        $filePrefix = self::getCallerFileName($backTraceIndex);
131        if (substr($filePrefix, -6) === 'clean.') {
132            $filePrefix = substr($filePrefix, 0, -6);
133        }
134
135        $filesToClean = array_merge(
136            array_map(
137                function($fileExtension) use ($filePrefix) {
138                    return $filePrefix . $fileExtension;
139                },
140                self::$supportedFiles
141            ),
142            array_map(
143                function($fileExtension) {
144                    return __DIR__ . '/' . $fileExtension;
145                },
146                self::$filesToClean
147            )
148        );
149        // clean all the root files
150        foreach ($filesToClean as $filePattern) {
151            foreach (glob($filePattern) as $filePath) {
152                unlink($filePath);
153            }
154        }
155        // clean config files
156        if (is_dir(self::CONF_DIR)) {
157            foreach(glob(self::CONF_DIR . '/*.conf') as $name) {
158                unlink($name);
159            }
160            rmdir(self::CONF_DIR);
161        }
162    }
163
164    /**
165     * @param int $backTraceIndex
166     * @return string
167     */
168    static private function getCallerFileName($backTraceIndex = 1)
169    {
170        $backtrace = debug_backtrace();
171        if (isset($backtrace[$backTraceIndex]['file'])) {
172            $filePath = $backtrace[$backTraceIndex]['file'];
173        } else {
174            $filePath = __FILE__;
175        }
176
177        return substr($filePath, 0, -strlen(pathinfo($filePath, PATHINFO_EXTENSION)));
178    }
179
180    /**
181     * @return bool|string
182     */
183    static public function findExecutable()
184    {
185        $phpPath = getenv("TEST_PHP_EXECUTABLE");
186        for ($i = 0; $i < 2; $i++) {
187            $slashPosition = strrpos($phpPath, "/");
188            if ($slashPosition) {
189                $phpPath = substr($phpPath, 0, $slashPosition);
190            } else {
191                break;
192            }
193        }
194
195        if ($phpPath && is_dir($phpPath)) {
196            if (file_exists($phpPath."/fpm/php-fpm") && is_executable($phpPath."/fpm/php-fpm")) {
197                /* gotcha */
198                return $phpPath."/fpm/php-fpm";
199            }
200            $phpSbinFpmi = $phpPath."/sbin/php-fpm";
201            if (file_exists($phpSbinFpmi) && is_executable($phpSbinFpmi)) {
202                return $phpSbinFpmi;
203            }
204        }
205
206        // try local php-fpm
207        $fpmPath = dirname(__DIR__) . '/php-fpm';
208        if (file_exists($fpmPath) && is_executable($fpmPath)) {
209            return $fpmPath;
210        }
211
212        return false;
213    }
214
215    /**
216     * Skip test if any of the supplied files does not exist.
217     *
218     * @param mixed $files
219     */
220    static public function skipIfAnyFileDoesNotExist($files)
221    {
222        if (!is_array($files)) {
223            $files = array($files);
224        }
225        foreach ($files as $file) {
226            if (!file_exists($file)) {
227                die("skip File $file does not exist");
228            }
229        }
230    }
231
232    /**
233     * Skip test if config file is invalid.
234     *
235     * @param string $configTemplate
236     * @throws \Exception
237     */
238    static public function skipIfConfigFails(string $configTemplate)
239    {
240        $tester = new self($configTemplate, '', [], self::getCallerFileName());
241        $testResult = $tester->testConfig();
242        if ($testResult !== null) {
243            self::clean(2);
244            die("skip $testResult");
245        }
246    }
247
248    /**
249     * Skip test if IPv6 is not supported.
250     */
251    static public function skipIfIPv6IsNotSupported()
252    {
253        @stream_socket_client('tcp://[::1]:0', $errno);
254        if ($errno != 111) {
255            die('skip IPv6 is not supported.');
256        }
257    }
258
259    /**
260     * Skip if running on Travis.
261     *
262     * @param $message
263     */
264    static public function skipIfTravis($message)
265    {
266        if (getenv("TRAVIS")) {
267            die('skip Travis: ' . $message);
268        }
269    }
270
271    /**
272     * Tester constructor.
273     *
274     * @param string|array $configTemplate
275     * @param string $code
276     * @param array $options
277     * @param string $fileName
278     */
279    public function __construct(
280        $configTemplate,
281        string $code = '',
282        array $options = [],
283        $fileName = null
284    ) {
285        $this->configTemplate = $configTemplate;
286        $this->code = $code;
287        $this->options = $options;
288        $this->fileName = $fileName ?: self::getCallerFileName();
289        $this->logTool = new LogTool();
290        $this->debug = (bool) getenv('TEST_FPM_DEBUG');
291    }
292
293    /**
294     * @param string $ini
295     */
296    public function setUserIni(string $ini)
297    {
298        $iniFile = __DIR__ . '/.user.ini';
299        file_put_contents($iniFile, $ini);
300    }
301
302    /**
303     * Test configuration file.
304     *
305     * @return null|string
306     * @throws \Exception
307     */
308    public function testConfig()
309    {
310        $configFile = $this->createConfig();
311        $cmd = self::findExecutable() . ' -t -y ' . $configFile . ' 2>&1';
312        exec($cmd, $output, $code);
313        if ($code) {
314            return preg_replace("/\[.+?\]/", "", $output[0]);
315        }
316
317        return null;
318    }
319
320    /**
321     * Start PHP-FPM master process
322     *
323     * @param string $extraArgs
324     * @return bool
325     * @throws \Exception
326     */
327    public function start(string $extraArgs = '')
328    {
329        $configFile = $this->createConfig();
330        $desc = $this->outDesc ? [] : [1 => array('pipe', 'w')];
331        $asRoot = getenv('TEST_FPM_RUN_AS_ROOT') ? '--allow-to-run-as-root' : '';
332        $cmd = self::findExecutable() . " $asRoot -F -O -y $configFile $extraArgs";
333        /* Since it's not possible to spawn a process under linux without using a
334         * shell in php (why?!?) we need a little shell trickery, so that we can
335         * actually kill php-fpm */
336        $this->masterProcess = proc_open(
337            "killit () { kill \$child 2> /dev/null; }; " .
338                "trap killit TERM; $cmd 2>&1 & child=\$!; wait",
339            $desc,
340            $pipes
341        );
342        register_shutdown_function(
343            function($masterProcess) use($configFile) {
344                @unlink($configFile);
345                if (is_resource($masterProcess)) {
346                    @proc_terminate($masterProcess);
347                    while (proc_get_status($masterProcess)['running']) {
348                        usleep(10000);
349                    }
350                }
351            },
352            $this->masterProcess
353        );
354        if (!$this->outDesc !== false) {
355            $this->outDesc = $pipes[1];
356        }
357
358        return true;
359    }
360
361    /**
362     * Run until needle is found in the log.
363     *
364     * @param string $needle
365     * @param int $max
366     * @return bool
367     * @throws \Exception
368     */
369    public function runTill(string $needle, $max = 10)
370    {
371        $this->start();
372        $found = false;
373        for ($i = 0; $i < $max; $i++) {
374            $line = $this->getLogLine();
375            if (is_null($line)) {
376                break;
377            }
378            if (preg_match($needle, $line) === 1) {
379                $found = true;
380                break;
381            }
382        }
383        $this->close(true);
384
385        if (!$found) {
386            return $this->error("The search pattern not found");
387        }
388
389        return true;
390    }
391
392    /**
393     * Check if connection works.
394     *
395     * @param string $host
396     * @param null|string $successMessage
397     * @param null|string $errorMessage
398     * @param int $attempts
399     * @param int $delay
400     */
401    public function checkConnection(
402        $host = '127.0.0.1',
403        $successMessage = null,
404        $errorMessage = 'Connection failed',
405        $attempts = 20,
406        $delay = 50000
407    ) {
408        $i = 0;
409        do {
410            if ($i > 0 && $delay > 0) {
411                usleep($delay);
412            }
413            $fp = @fsockopen($host, $this->getPort());
414        } while ((++$i < $attempts) && !$fp);
415
416        if ($fp) {
417            $this->message($successMessage);
418            fclose($fp);
419        } else {
420            $this->message($errorMessage);
421        }
422    }
423
424
425    /**
426     * Execute request with parameters ordered for better checking.
427     *
428     * @param string $address
429     * @param string|null $successMessage
430     * @param string|null $errorMessage
431     * @param string $uri
432     * @param string $query
433     * @param array $headers
434     * @return Response
435     */
436    public function checkRequest(
437        string $address,
438        string $successMessage = null,
439        string $errorMessage = null,
440        $uri = '/ping',
441        $query = '',
442        $headers = []
443    ) {
444        return $this->request($query, $headers, $uri, $address, $successMessage, $errorMessage);
445    }
446
447    /**
448     * Execute and check ping request.
449     *
450     * @param string $address
451     * @param string $pingPath
452     * @param string $pingResponse
453     */
454    public function ping(
455        string $address = '{{ADDR}}',
456        string $pingResponse = 'pong',
457        string $pingPath = '/ping'
458    ) {
459        $response = $this->request('', [], $pingPath, $address);
460        $response->expectBody($pingResponse, 'text/plain');
461    }
462
463    /**
464     * Execute and check status request(s).
465     *
466     * @param array $expectedFields
467     * @param string|null $address
468     * @param string $statusPath
469     * @param mixed $formats
470     * @throws \Exception
471     */
472    public function status(
473        array $expectedFields,
474        string $address = null,
475        string $statusPath = '/status',
476        $formats = ['plain', 'html', 'xml', 'json']
477    ) {
478        if (!is_array($formats)) {
479            $formats = [$formats];
480        }
481
482        require_once "status.inc";
483        $status = new Status();
484        foreach ($formats as $format) {
485            $query = $format === 'plain' ? '' : $format;
486            $response = $this->request($query, [], $statusPath, $address);
487            $status->checkStatus($response, $expectedFields, $format);
488        }
489    }
490
491    /**
492     * Execute request.
493     *
494     * @param string $query
495     * @param array $headers
496     * @param string|null $uri
497     * @param string|null $address
498     * @param string|null $successMessage
499     * @param string|null $errorMessage
500     * @param bool $connKeepAlive
501     * @return Response
502     */
503    public function request(
504        string $query = '',
505        array $headers = [],
506        string $uri = null,
507        string $address = null,
508        string $successMessage = null,
509        string $errorMessage = null,
510        bool $connKeepAlive = false
511    ) {
512        if ($this->hasError()) {
513            return new Response(null, true);
514        }
515        if (is_null($uri)) {
516            $uri = $this->makeSourceFile();
517        }
518
519        $params = array_merge(
520            [
521                'GATEWAY_INTERFACE' => 'FastCGI/1.0',
522                'REQUEST_METHOD'    => 'GET',
523                'SCRIPT_FILENAME'   => $uri,
524                'SCRIPT_NAME'       => $uri,
525                'QUERY_STRING'      => $query,
526                'REQUEST_URI'       => $uri . ($query ? '?'.$query : ""),
527                'DOCUMENT_URI'      => $uri,
528                'SERVER_SOFTWARE'   => 'php/fcgiclient',
529                'REMOTE_ADDR'       => '127.0.0.1',
530                'REMOTE_PORT'       => '7777',
531                'SERVER_ADDR'       => '127.0.0.1',
532                'SERVER_PORT'       => '80',
533                'SERVER_NAME'       => php_uname('n'),
534                'SERVER_PROTOCOL'   => 'HTTP/1.1',
535                'DOCUMENT_ROOT'     => __DIR__,
536                'CONTENT_TYPE'      => '',
537                'CONTENT_LENGTH'    => 0
538            ],
539            $headers
540        );
541        try {
542            $this->response = new Response(
543                $this->getClient($address, $connKeepAlive)->request_data($params, false)
544            );
545            $this->message($successMessage);
546        } catch (\Exception $exception) {
547            if ($errorMessage === null) {
548                $this->error("Request failed", $exception);
549            } else {
550                $this->message($errorMessage);
551            }
552            $this->response = new Response();
553        }
554        if ($this->debug) {
555            $this->response->debugOutput();
556        }
557        return $this->response;
558    }
559
560    /**
561     * Get client.
562     *
563     * @param string $address
564     * @param bool $keepAlive
565     * @return Client
566     */
567    private function getClient(string $address = null, $keepAlive = false)
568    {
569        $address = $address ? $this->processTemplate($address) : $this->getAddr();
570        if ($address[0] === '/') { // uds
571            $host = 'unix://' . $address;
572            $port = -1;
573        } elseif ($address[0] === '[') { // ipv6
574            $addressParts = explode(']:', $address);
575            $host = $addressParts[0];
576            if (isset($addressParts[1])) {
577                $host .= ']';
578                $port = $addressParts[1];
579            } else {
580                $port = $this->getPort();
581            }
582        } else { // ipv4
583            $addressParts = explode(':', $address);
584            $host = $addressParts[0];
585            $port = $addressParts[1] ?? $this->getPort();
586        }
587
588        if (!$keepAlive) {
589            return new Client($host, $port);
590        }
591
592        if (!isset($this->clients[$host][$port])) {
593            $client = new Client($host, $port);
594            $client->setKeepAlive(true);
595            $this->clients[$host][$port] = $client;
596        }
597
598        return $this->clients[$host][$port];
599    }
600
601    /**
602     * Display logs
603     *
604     * @param int $number
605     * @param string $ignore
606     */
607    public function displayLog(int $number = 1, string $ignore = 'systemd')
608    {
609        /* Read $number lines or until EOF */
610        while ($number > 0 || ($number < 0 && !feof($this->outDesc))) {
611            $a = fgets($this->outDesc);
612            if (empty($ignore) || !strpos($a, $ignore)) {
613                echo $a;
614                $number--;
615            }
616        }
617    }
618
619    /**
620     * Get a single log line
621     *
622     * @return null|string
623     */
624    private function getLogLine()
625    {
626        $read = [$this->outDesc];
627        $write = null;
628        $except = null;
629        if (stream_select($read, $write, $except, 2 )) {
630            return fgets($this->outDesc);
631        } else {
632            return null;
633        }
634    }
635
636    /**
637     * Get log lines
638     *
639     * @param int $number
640     * @param bool $skipBlank
641     * @param string $ignore
642     * @return array
643     */
644    public function getLogLines(int $number = 1, bool $skipBlank = false, string $ignore = 'systemd')
645    {
646        $lines = [];
647        /* Read $n lines or until EOF */
648        while ($number > 0 || ($number < 0 && !feof($this->outDesc))) {
649            $line = $this->getLogLine();
650            if (is_null($line)) {
651                break;
652            }
653            if ((empty($ignore) || !strpos($line, $ignore)) && (!$skipBlank || strlen(trim($line)) > 0)) {
654                $lines[] = $line;
655                $number--;
656            }
657        }
658
659        return $lines;
660    }
661
662    /**
663     * @return mixed|string
664     */
665    public function getLastLogLine()
666    {
667        $lines = $this->getLogLines();
668
669        return $lines[0] ?? '';
670    }
671
672    /**
673     * Send signal to the supplied PID or the server PID.
674     *
675     * @param string $signal
676     * @param int|null $pid
677     * @return string
678     */
679    public function signal($signal, int $pid = null)
680    {
681        if (is_null($pid)) {
682            $pid = $this->getPid();
683        }
684
685        return exec("kill -$signal $pid");
686    }
687
688    /**
689     * Terminate master process
690     */
691    public function terminate()
692    {
693        proc_terminate($this->masterProcess);
694    }
695
696    /**
697     * Close all open descriptors and process resources
698     *
699     * @param bool $terminate
700     */
701    public function close($terminate = false)
702    {
703        if ($terminate) {
704            $this->terminate();
705        }
706        fclose($this->outDesc);
707        proc_close($this->masterProcess);
708    }
709
710    /**
711     * Create a config file.
712     *
713     * @param string $extension
714     * @return string
715     * @throws \Exception
716     */
717    private function createConfig($extension = 'ini')
718    {
719        if (is_array($this->configTemplate)) {
720            $configTemplates = $this->configTemplate;
721            if (!isset($configTemplates['main'])) {
722                throw new \Exception('The config template array has to have main config');
723            }
724            $mainTemplate = $configTemplates['main'];
725            unset($configTemplates['main']);
726            if (!is_dir(self::CONF_DIR)) {
727                mkdir(self::CONF_DIR);
728            }
729            foreach ($configTemplates as $name => $configTemplate) {
730                $this->makeFile(
731                    'conf',
732                    $this->processTemplate($configTemplate),
733                    self::CONF_DIR,
734                    $name
735                );
736            }
737        } else {
738            $mainTemplate = $this->configTemplate;
739        }
740
741        return $this->makeFile($extension, $this->processTemplate($mainTemplate));
742    }
743
744    /**
745     * Process template string.
746     *
747     * @param string $template
748     * @return string
749     */
750    private function processTemplate(string $template)
751    {
752        $vars = [
753            'FILE:LOG:ACC' => ['getAbsoluteFile', self::FILE_EXT_LOG_ACC],
754            'FILE:LOG:ERR' => ['getAbsoluteFile', self::FILE_EXT_LOG_ERR],
755            'FILE:LOG:SLOW' => ['getAbsoluteFile', self::FILE_EXT_LOG_SLOW],
756            'FILE:PID' => ['getAbsoluteFile', self::FILE_EXT_PID],
757            'RFILE:LOG:ACC' => ['getRelativeFile', self::FILE_EXT_LOG_ACC],
758            'RFILE:LOG:ERR' => ['getRelativeFile', self::FILE_EXT_LOG_ERR],
759            'RFILE:LOG:SLOW' => ['getRelativeFile', self::FILE_EXT_LOG_SLOW],
760            'RFILE:PID' => ['getRelativeFile', self::FILE_EXT_PID],
761            'ADDR:IPv4' => ['getAddr', 'ipv4'],
762            'ADDR:IPv4:ANY' => ['getAddr', 'ipv4-any'],
763            'ADDR:IPv6' => ['getAddr', 'ipv6'],
764            'ADDR:IPv6:ANY' => ['getAddr', 'ipv6-any'],
765            'ADDR:UDS' => ['getAddr', 'uds'],
766            'PORT' => ['getPort', 'ip'],
767            'INCLUDE:CONF' => self::CONF_DIR . '/*.conf',
768        ];
769        $aliases = [
770            'ADDR' => 'ADDR:IPv4',
771            'FILE:LOG' => 'FILE:LOG:ERR',
772        ];
773        foreach ($aliases as $aliasName => $aliasValue) {
774            $vars[$aliasName] = $vars[$aliasValue];
775        }
776
777        return preg_replace_callback(
778            '/{{([a-zA-Z0-9:]+)(\[\w+\])?}}/',
779            function ($matches) use ($vars) {
780                $varName = $matches[1];
781                if (!isset($vars[$varName])) {
782                    $this->error("Invalid config variable $varName");
783                    return 'INVALID';
784                }
785                $pool = $matches[2] ?? 'default';
786                $varValue = $vars[$varName];
787                if (is_string($varValue)) {
788                    return $varValue;
789                }
790                $functionName = array_shift($varValue);
791                $varValue[] = $pool;
792                return call_user_func_array([$this, $functionName], $varValue);
793            },
794            $template
795        );
796    }
797
798    /**
799     * @param string $type
800     * @param string $pool
801     * @return string
802     */
803    public function getAddr(string $type = 'ipv4', $pool = 'default')
804    {
805        $port = $this->getPort($type, $pool, true);
806        if ($type === 'uds') {
807            return $this->getFile($port . '.sock');
808        }
809
810        return $this->getHost($type) . ':' . $port;
811    }
812
813    /**
814     * @param string $type
815     * @param string $pool
816     * @param bool $useAsId
817     * @return int
818     */
819    public function getPort(string $type = 'ip', $pool = 'default', $useAsId = false)
820    {
821        if ($type === 'uds' && !$useAsId) {
822            return -1;
823        }
824
825        if (isset($this->ports['values'][$pool])) {
826            return $this->ports['values'][$pool];
827        }
828        $port = ($this->ports['last'] ?? 9000 + PHP_INT_SIZE - 1) + 1;
829        $this->ports['values'][$pool] = $this->ports['last'] = $port;
830
831        return $port;
832    }
833
834    /**
835     * @param string $type
836     * @return string
837     */
838    public function getHost(string $type = 'ipv4')
839    {
840        switch ($type) {
841            case 'ipv6-any':
842                return '[::]';
843            case 'ipv6':
844                return '[::1]';
845            case 'ipv4-any':
846                return '0.0.0.0';
847            default:
848                return '127.0.0.1';
849        }
850    }
851
852    /**
853     * Get listen address.
854     *
855     * @param string|null $template
856     * @return string
857     */
858    public function getListen($template = null)
859    {
860        return $template ? $this->processTemplate($template) : $this->getAddr();
861    }
862
863    /**
864     * Get PID.
865     *
866     * @return int
867     */
868    public function getPid()
869    {
870        $pidFile = $this->getFile('pid');
871        if (!is_file($pidFile)) {
872            return (int) $this->error("PID file has not been created");
873        }
874        $pidContent = file_get_contents($pidFile);
875        if (!is_numeric($pidContent)) {
876            return (int) $this->error("PID content '$pidContent' is not integer");
877        }
878
879        return (int) $pidContent;
880    }
881
882
883    /**
884     * @param string $extension
885     * @param string|null $dir
886     * @param string|null $name
887     * @return string
888     */
889    private function getFile(string $extension, $dir = null, $name = null)
890    {
891        $fileName = (is_null($name) ? $this->fileName : $name . '.') . $extension;
892
893        return is_null($dir) ? $fileName : $dir . '/'  . $fileName;
894    }
895
896    /**
897     * @param string $extension
898     * @return string
899     */
900    private function getAbsoluteFile(string $extension)
901    {
902        return $this->getFile($extension);
903    }
904
905    /**
906     * @param string $extension
907     * @return string
908     */
909    private function getRelativeFile(string $extension)
910    {
911        $fileName = rtrim(basename($this->fileName), '.');
912
913        return $this->getFile($extension, null, $fileName);
914    }
915
916    /**
917     * @param string $extension
918     * @param string $prefix
919     * @return string
920     */
921    private function getPrefixedFile(string $extension, string $prefix = null)
922    {
923        $fileName = rtrim($this->fileName, '.');
924        if (!is_null($prefix)) {
925            $fileName = $prefix . '/' . basename($fileName);
926        }
927
928        return $this->getFile($extension, null, $fileName);
929    }
930
931    /**
932     * @param string $extension
933     * @param string $content
934     * @param string|null $dir
935     * @param string|null $name
936     * @return string
937     */
938    private function makeFile(string $extension, string $content = '', $dir = null, $name = null)
939    {
940        $filePath = $this->getFile($extension, $dir, $name);
941        file_put_contents($filePath, $content);
942
943        return $filePath;
944    }
945
946    /**
947     * @return string
948     */
949    public function makeSourceFile()
950    {
951        return $this->makeFile('src.php', $this->code);
952    }
953
954    /**
955     * @param string|null $msg
956     */
957    private function message($msg)
958    {
959        if ($msg !== null) {
960            echo "$msg\n";
961        }
962    }
963
964    /**
965     * @param string $msg
966     * @param \Exception|null $exception
967     */
968    private function error($msg, \Exception $exception = null)
969    {
970        $this->error =  'ERROR: ' . $msg;
971        if ($exception) {
972            $this->error .= '; EXCEPTION: ' . $exception->getMessage();
973        }
974        $this->error .= "\n";
975
976        echo $this->error;
977    }
978
979    /**
980     * @return bool
981     */
982    private function hasError()
983    {
984        return !is_null($this->error) || !is_null($this->logTool->getError());
985    }
986
987    /**
988     * Expect file with a supplied extension to exist.
989     *
990     * @param string $extension
991     * @param string $prefix
992     * @return bool
993     */
994    public function expectFile(string $extension, $prefix = null)
995    {
996        $filePath = $this->getPrefixedFile($extension, $prefix);
997        if (!file_exists($filePath)) {
998            return $this->error("The file $filePath does not exist");
999        }
1000
1001        return true;
1002    }
1003
1004    /**
1005     * Expect file with a supplied extension to not exist.
1006     *
1007     * @param string $extension
1008     * @param string $prefix
1009     * @return bool
1010     */
1011    public function expectNoFile(string $extension, $prefix = null)
1012    {
1013        $filePath = $this->getPrefixedFile($extension, $prefix);
1014        if (file_exists($filePath)) {
1015            return $this->error("The file $filePath exists");
1016        }
1017
1018        return true;
1019    }
1020
1021    /**
1022     * Expect message to be written to FastCGI error stream.
1023     *
1024     * @param string $message
1025     * @param int $limit
1026     * @param int $repeat
1027     */
1028    public function expectFastCGIErrorMessage(
1029        string $message,
1030        int $limit = 1024,
1031        int $repeat = 0
1032    ) {
1033        $this->logTool->setExpectedMessage($message, $limit, $repeat);
1034        $this->logTool->checkTruncatedMessage($this->response->getErrorData());
1035    }
1036
1037    /**
1038     * Expect starting lines to be logged.
1039     */
1040    public function expectLogStartNotices()
1041    {
1042        $this->logTool->expectStartingLines($this->getLogLines(2));
1043    }
1044
1045    /**
1046     * Expect terminating lines to be logged.
1047     */
1048    public function expectLogTerminatingNotices()
1049    {
1050        $this->logTool->expectTerminatorLines($this->getLogLines(-1));
1051    }
1052
1053    /**
1054     * Expect log message that can span multiple lines.
1055     *
1056     * @param string $message
1057     * @param int $limit
1058     * @param int $repeat
1059     * @param bool $decorated
1060     * @param bool $wrapped
1061     */
1062    public function expectLogMessage(
1063        string $message,
1064        int $limit = 1024,
1065        int $repeat = 0,
1066        bool $decorated = true,
1067        bool $wrapped = true
1068    ) {
1069        $this->logTool->setExpectedMessage($message, $limit, $repeat);
1070        if ($wrapped) {
1071            $logLines = $this->getLogLines(-1, true);
1072            $this->logTool->checkWrappedMessage($logLines, true, $decorated);
1073        } else {
1074            $logLines = $this->getLogLines(1, true);
1075            $this->logTool->checkTruncatedMessage($logLines[0] ?? '');
1076        }
1077        if ($this->debug) {
1078            $this->message("-------------- LOG LINES: -------------");
1079            var_dump($logLines);
1080            $this->message("---------------------------------------\n");
1081        }
1082    }
1083
1084    /**
1085     * Expect a single log line.
1086     *
1087     * @param string $message
1088     * @return bool
1089     */
1090    public function expectLogLine(string $message, bool $is_stderr = true)
1091    {
1092        $messageLen = strlen($message);
1093        $limit = $messageLen > 1024 ? $messageLen + 16 : 1024;
1094        $this->logTool->setExpectedMessage($message, $limit);
1095        $logLines = $this->getLogLines(1, true);
1096        if ($this->debug) {
1097            $this->message("LOG LINE: " . ($logLines[0] ?? ''));
1098        }
1099
1100        return $this->logTool->checkWrappedMessage($logLines, false, true, $is_stderr);
1101    }
1102
1103    /**
1104     * Expect a log debug message.
1105     *
1106     * @param string $message
1107     * @param string|null $pool
1108     * @return bool
1109     */
1110    public function expectLogDebug(string $message, $pool = null)
1111    {
1112        return $this->logTool->expectDebug($this->getLastLogLine(), $message, $pool);
1113    }
1114
1115    /**
1116     * Expect a log notice.
1117     *
1118     * @param string $message
1119     * @param string|null $pool
1120     * @return bool
1121     */
1122    public function expectLogNotice(string $message, $pool = null)
1123    {
1124        return $this->logTool->expectNotice($this->getLastLogLine(), $message, $pool);
1125    }
1126
1127    /**
1128     * Expect a log warning.
1129     *
1130     * @param string $message
1131     * @param string|null $pool
1132     * @return bool
1133     */
1134    public function expectLogWarning(string $message, $pool = null)
1135    {
1136        return $this->logTool->expectWarning($this->getLastLogLine(), $message, $pool);
1137    }
1138
1139    /**
1140     * Expect a log error.
1141     *
1142     * @param string $message
1143     * @param string|null $pool
1144     * @return bool
1145     */
1146    public function expectLogError(string $message, $pool = null)
1147    {
1148        return $this->logTool->expectError($this->getLastLogLine(), $message, $pool);
1149    }
1150
1151    /**
1152     * Expect a log alert.
1153     *
1154     * @param string $message
1155     * @param string|null $pool
1156     * @return bool
1157     */
1158    public function expectLogAlert(string $message, $pool = null)
1159    {
1160        return $this->logTool->expectAlert($this->getLastLogLine(), $message, $pool);
1161    }
1162
1163    /**
1164     * Expect no log lines to be logged.
1165     *
1166     * @return bool
1167     */
1168    public function expectNoLogMessages()
1169    {
1170        $logLines = $this->getLogLines(-1, true);
1171        if (!empty($logLines)) {
1172            return $this->error(
1173                "Expected no log lines but following lines logged:\n" . implode("\n", $logLines)
1174            );
1175        }
1176
1177        return true;
1178    }
1179
1180    /**
1181     * Print content of access log.
1182     */
1183    public function printAccessLog()
1184    {
1185        $accessLog = $this->getFile('acc.log');
1186        if (is_file($accessLog)) {
1187            print file_get_contents($accessLog);
1188        }
1189    }
1190}
1191