Passed
Push — develop ( 30cf64...589229 )
by Guillaume
06:18 queued 04:10
created

PhptTestCase   F

Complexity

Total Complexity 110

Size/Duplication

Total Lines 727
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 348
dl 0
loc 727
rs 2
c 0
b 0
f 0
wmc 110

How to fix   Complexity   

Complex Class

Complex classes like PhptTestCase often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use PhptTestCase, and based on these observations, apply Extract Interface, too.

1
<?php declare(strict_types=1);
2
/*
3
 * This file is part of PHPUnit.
4
 *
5
 * (c) Sebastian Bergmann <[email protected]>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
namespace PHPUnit\Runner;
11
12
use PHPUnit\Framework\Assert;
13
use PHPUnit\Framework\AssertionFailedError;
14
use PHPUnit\Framework\ExpectationFailedException;
15
use PHPUnit\Framework\IncompleteTestError;
16
use PHPUnit\Framework\PHPTAssertionFailedError;
17
use PHPUnit\Framework\SelfDescribing;
18
use PHPUnit\Framework\SkippedTestError;
19
use PHPUnit\Framework\SyntheticSkippedError;
20
use PHPUnit\Framework\Test;
21
use PHPUnit\Framework\TestResult;
22
use PHPUnit\Util\PHP\AbstractPhpProcess;
23
use SebastianBergmann\Template\Template;
24
use SebastianBergmann\Timer\Timer;
25
26
/**
27
 * @internal This class is not covered by the backward compatibility promise for PHPUnit
28
 */
29
final class PhptTestCase implements SelfDescribing, Test
30
{
31
    /**
32
     * @var string[]
33
     */
34
    private const SETTINGS = [
35
        'allow_url_fopen=1',
36
        'auto_append_file=',
37
        'auto_prepend_file=',
38
        'disable_functions=',
39
        'display_errors=1',
40
        'docref_ext=.html',
41
        'docref_root=',
42
        'error_append_string=',
43
        'error_prepend_string=',
44
        'error_reporting=-1',
45
        'html_errors=0',
46
        'log_errors=0',
47
        'magic_quotes_runtime=0',
48
        'open_basedir=',
49
        'output_buffering=Off',
50
        'output_handler=',
51
        'report_memleaks=0',
52
        'report_zend_debug=0',
53
        'safe_mode=0',
54
        'xdebug.default_enable=0',
55
    ];
56
57
    /**
58
     * @var string
59
     */
60
    private $filename;
61
62
    /**
63
     * @var AbstractPhpProcess
64
     */
65
    private $phpUtil;
66
67
    /**
68
     * @var string
69
     */
70
    private $output = '';
71
72
    /**
73
     * Constructs a test case with the given filename.
74
     *
75
     * @throws Exception
76
     */
77
    public function __construct(string $filename, AbstractPhpProcess $phpUtil = null)
78
    {
79
        if (!\is_file($filename)) {
80
            throw new Exception(
81
                \sprintf(
82
                    'File "%s" does not exist.',
83
                    $filename
84
                )
85
            );
86
        }
87
88
        $this->filename = $filename;
89
        $this->phpUtil  = $phpUtil ?: AbstractPhpProcess::factory();
90
    }
91
92
    /**
93
     * Counts the number of test cases executed by run(TestResult result).
94
     */
95
    public function count(): int
96
    {
97
        return 1;
98
    }
99
100
    /**
101
     * Runs a test and collects its result in a TestResult instance.
102
     *
103
     * @throws Exception
104
     * @throws \SebastianBergmann\CodeCoverage\CoveredCodeNotExecutedException
105
     * @throws \SebastianBergmann\CodeCoverage\InvalidArgumentException
106
     * @throws \SebastianBergmann\CodeCoverage\RuntimeException
107
     * @throws \SebastianBergmann\CodeCoverage\UnintentionallyCoveredCodeException
108
     * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException
109
     */
110
    public function run(TestResult $result = null): TestResult
111
    {
112
        if ($result === null) {
113
            $result = new TestResult;
114
        }
115
116
        try {
117
            $sections = $this->parse();
118
        } catch (Exception $e) {
119
            $result->startTest($this);
120
            $result->addFailure($this, new SkippedTestError($e->getMessage()), 0);
121
            $result->endTest($this, 0);
122
123
            return $result;
124
        }
125
126
        $code     = $this->render($sections['FILE']);
127
        $xfail    = false;
128
        $settings = $this->parseIniSection(self::SETTINGS);
129
130
        $result->startTest($this);
131
132
        if (isset($sections['INI'])) {
133
            $settings = $this->parseIniSection($sections['INI'], $settings);
134
        }
135
136
        if (isset($sections['ENV'])) {
137
            $env = $this->parseEnvSection($sections['ENV']);
138
            $this->phpUtil->setEnv($env);
139
        }
140
141
        $this->phpUtil->setUseStderrRedirection(true);
142
143
        if ($result->enforcesTimeLimit()) {
144
            $this->phpUtil->setTimeout($result->getTimeoutForLargeTests());
145
        }
146
147
        $skip = $this->runSkip($sections, $result, $settings);
148
149
        if ($skip) {
150
            return $result;
151
        }
152
153
        if (isset($sections['XFAIL'])) {
154
            $xfail = \trim($sections['XFAIL']);
155
        }
156
157
        if (isset($sections['STDIN'])) {
158
            $this->phpUtil->setStdin($sections['STDIN']);
159
        }
160
161
        if (isset($sections['ARGS'])) {
162
            $this->phpUtil->setArgs($sections['ARGS']);
163
        }
164
165
        if ($result->getCollectCodeCoverageInformation()) {
166
            $this->renderForCoverage($code);
167
        }
168
169
        $timer = new Timer;
170
        $timer->start();
171
172
        $jobResult    = $this->phpUtil->runJob($code, $this->stringifyIni($settings));
173
        $time         = $timer->stop()->asSeconds();
174
        $this->output = $jobResult['stdout'] ?? '';
175
176
        if ($result->getCollectCodeCoverageInformation() && ($coverage = $this->cleanupForCoverage())) {
177
            $result->getCodeCoverage()->append($coverage, $this, true, [], [], true);
178
        }
179
180
        try {
181
            $this->assertPhptExpectation($sections, $this->output);
182
        } catch (AssertionFailedError $e) {
183
            $failure = $e;
184
185
            if ($xfail !== false) {
186
                $failure = new IncompleteTestError($xfail, 0, $e);
187
            } elseif ($e instanceof ExpectationFailedException) {
188
                $comparisonFailure = $e->getComparisonFailure();
189
190
                if ($comparisonFailure) {
191
                    $diff = $comparisonFailure->getDiff();
192
                } else {
193
                    $diff = $e->getMessage();
194
                }
195
196
                $hint    = $this->getLocationHintFromDiff($diff, $sections);
197
                $trace   = \array_merge($hint, \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS));
198
                $failure = new PHPTAssertionFailedError(
199
                    $e->getMessage(),
200
                    0,
201
                    $trace[0]['file'],
202
                    $trace[0]['line'],
203
                    $trace,
204
                    $comparisonFailure ? $diff : ''
205
                );
206
            }
207
208
            $result->addFailure($this, $failure, $time);
209
        } catch (\Throwable $t) {
210
            $result->addError($this, $t, $time);
211
        }
212
213
        if ($xfail !== false && $result->allCompletelyImplemented()) {
214
            $result->addFailure($this, new IncompleteTestError('XFAIL section but test passes'), $time);
215
        }
216
217
        $this->runClean($sections);
218
219
        $result->endTest($this, $time);
220
221
        return $result;
222
    }
223
224
    /**
225
     * Returns the name of the test case.
226
     */
227
    public function getName(): string
228
    {
229
        return $this->toString();
230
    }
231
232
    /**
233
     * Returns a string representation of the test case.
234
     */
235
    public function toString(): string
236
    {
237
        return $this->filename;
238
    }
239
240
    public function usesDataProvider(): bool
241
    {
242
        return false;
243
    }
244
245
    public function getNumAssertions(): int
246
    {
247
        return 1;
248
    }
249
250
    public function getActualOutput(): string
251
    {
252
        return $this->output;
253
    }
254
255
    public function hasOutput(): bool
256
    {
257
        return !empty($this->output);
258
    }
259
260
    /**
261
     * Parse --INI-- section key value pairs and return as array.
262
     *
263
     * @param array|string $content
264
     */
265
    private function parseIniSection($content, array $ini = []): array
266
    {
267
        if (\is_string($content)) {
268
            $content = \explode("\n", \trim($content));
269
        }
270
271
        foreach ($content as $setting) {
272
            if (\strpos($setting, '=') === false) {
273
                continue;
274
            }
275
276
            $setting = \explode('=', $setting, 2);
277
            $name    = \trim($setting[0]);
278
            $value   = \trim($setting[1]);
279
280
            if ($name === 'extension' || $name === 'zend_extension') {
281
                if (!isset($ini[$name])) {
282
                    $ini[$name] = [];
283
                }
284
285
                $ini[$name][] = $value;
286
287
                continue;
288
            }
289
290
            $ini[$name] = $value;
291
        }
292
293
        return $ini;
294
    }
295
296
    private function parseEnvSection(string $content): array
297
    {
298
        $env = [];
299
300
        foreach (\explode("\n", \trim($content)) as $e) {
301
            $e = \explode('=', \trim($e), 2);
302
303
            if (!empty($e[0]) && isset($e[1])) {
304
                $env[$e[0]] = $e[1];
305
            }
306
        }
307
308
        return $env;
309
    }
310
311
    /**
312
     * @throws ExpectationFailedException
313
     * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException
314
     * @throws Exception
315
     */
316
    private function assertPhptExpectation(array $sections, string $output): void
317
    {
318
        $assertions = [
319
            'EXPECT'      => 'assertEquals',
320
            'EXPECTF'     => 'assertStringMatchesFormat',
321
            'EXPECTREGEX' => 'assertMatchesRegularExpression',
322
        ];
323
324
        $actual = \preg_replace('/\r\n/', "\n", \trim($output));
325
326
        foreach ($assertions as $sectionName => $sectionAssertion) {
327
            if (isset($sections[$sectionName])) {
328
                $sectionContent = \preg_replace('/\r\n/', "\n", \trim($sections[$sectionName]));
329
                $expected       = $sectionName === 'EXPECTREGEX' ? "/{$sectionContent}/" : $sectionContent;
330
331
                if ($expected === null) {
332
                    throw new Exception('No PHPT expectation found');
333
                }
334
335
                Assert::$sectionAssertion($expected, $actual);
336
337
                return;
338
            }
339
        }
340
341
        throw new Exception('No PHPT assertion found');
342
    }
343
344
    /**
345
     * @throws \SebastianBergmann\RecursionContext\InvalidArgumentException
346
     */
347
    private function runSkip(array &$sections, TestResult $result, array $settings): bool
348
    {
349
        if (!isset($sections['SKIPIF'])) {
350
            return false;
351
        }
352
353
        $skipif    = $this->render($sections['SKIPIF']);
354
        $jobResult = $this->phpUtil->runJob($skipif, $this->stringifyIni($settings));
355
356
        if (!\strncasecmp('skip', \ltrim($jobResult['stdout']), 4)) {
357
            $message = '';
358
359
            if (\preg_match('/^\s*skip\s*(.+)\s*/i', $jobResult['stdout'], $skipMatch)) {
360
                $message = \substr($skipMatch[1], 2);
361
            }
362
363
            $hint  = $this->getLocationHint($message, $sections, 'SKIPIF');
364
            $trace = \array_merge($hint, \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS));
365
            $result->addFailure(
366
                $this,
367
                new SyntheticSkippedError($message, 0, $trace[0]['file'], $trace[0]['line'], $trace),
368
                0
369
            );
370
            $result->endTest($this, 0);
371
372
            return true;
373
        }
374
375
        return false;
376
    }
377
378
    private function runClean(array &$sections): void
379
    {
380
        $this->phpUtil->setStdin('');
381
        $this->phpUtil->setArgs('');
382
383
        if (isset($sections['CLEAN'])) {
384
            $cleanCode = $this->render($sections['CLEAN']);
385
386
            $this->phpUtil->runJob($cleanCode, self::SETTINGS);
387
        }
388
    }
389
390
    /**
391
     * @throws Exception
392
     */
393
    private function parse(): array
394
    {
395
        $sections = [];
396
        $section  = '';
397
398
        $unsupportedSections = [
399
            'CGI',
400
            'COOKIE',
401
            'DEFLATE_POST',
402
            'EXPECTHEADERS',
403
            'EXTENSIONS',
404
            'GET',
405
            'GZIP_POST',
406
            'HEADERS',
407
            'PHPDBG',
408
            'POST',
409
            'POST_RAW',
410
            'PUT',
411
            'REDIRECTTEST',
412
            'REQUEST',
413
        ];
414
415
        $lineNr = 0;
416
417
        foreach (\file($this->filename) as $line) {
418
            $lineNr++;
419
420
            if (\preg_match('/^--([_A-Z]+)--/', $line, $result)) {
421
                $section                        = $result[1];
422
                $sections[$section]             = '';
423
                $sections[$section . '_offset'] = $lineNr;
424
425
                continue;
426
            }
427
428
            if (empty($section)) {
429
                throw new Exception('Invalid PHPT file: empty section header');
430
            }
431
432
            $sections[$section] .= $line;
433
        }
434
435
        if (isset($sections['FILEEOF'])) {
436
            $sections['FILE'] = \rtrim($sections['FILEEOF'], "\r\n");
437
            unset($sections['FILEEOF']);
438
        }
439
440
        $this->parseExternal($sections);
441
442
        if (!$this->validate($sections)) {
443
            throw new Exception('Invalid PHPT file');
444
        }
445
446
        foreach ($unsupportedSections as $section) {
447
            if (isset($sections[$section])) {
448
                throw new Exception(
449
                    "PHPUnit does not support PHPT {$section} sections"
450
                );
451
            }
452
        }
453
454
        return $sections;
455
    }
456
457
    /**
458
     * @throws Exception
459
     */
460
    private function parseExternal(array &$sections): void
461
    {
462
        $allowSections = [
463
            'FILE',
464
            'EXPECT',
465
            'EXPECTF',
466
            'EXPECTREGEX',
467
        ];
468
        $testDirectory = \dirname($this->filename) . \DIRECTORY_SEPARATOR;
469
470
        foreach ($allowSections as $section) {
471
            if (isset($sections[$section . '_EXTERNAL'])) {
472
                $externalFilename = \trim($sections[$section . '_EXTERNAL']);
473
474
                if (!\is_file($testDirectory . $externalFilename) ||
475
                    !\is_readable($testDirectory . $externalFilename)) {
476
                    throw new Exception(
477
                        \sprintf(
478
                            'Could not load --%s-- %s for PHPT file',
479
                            $section . '_EXTERNAL',
480
                            $testDirectory . $externalFilename
481
                        )
482
                    );
483
                }
484
485
                $sections[$section] = \file_get_contents($testDirectory . $externalFilename);
486
            }
487
        }
488
    }
489
490
    private function validate(array &$sections): bool
491
    {
492
        $requiredSections = [
493
            'FILE',
494
            [
495
                'EXPECT',
496
                'EXPECTF',
497
                'EXPECTREGEX',
498
            ],
499
        ];
500
501
        foreach ($requiredSections as $section) {
502
            if (\is_array($section)) {
503
                $foundSection = false;
504
505
                foreach ($section as $anySection) {
506
                    if (isset($sections[$anySection])) {
507
                        $foundSection = true;
508
509
                        break;
510
                    }
511
                }
512
513
                if (!$foundSection) {
514
                    return false;
515
                }
516
517
                continue;
518
            }
519
520
            if (!isset($sections[$section])) {
521
                return false;
522
            }
523
        }
524
525
        return true;
526
    }
527
528
    private function render(string $code): string
529
    {
530
        return \str_replace(
531
            [
532
                '__DIR__',
533
                '__FILE__',
534
            ],
535
            [
536
                "'" . \dirname($this->filename) . "'",
537
                "'" . $this->filename . "'",
538
            ],
539
            $code
540
        );
541
    }
542
543
    private function getCoverageFiles(): array
544
    {
545
        $baseDir  = \dirname(\realpath($this->filename)) . \DIRECTORY_SEPARATOR;
546
        $basename = \basename($this->filename, 'phpt');
547
548
        return [
549
            'coverage' => $baseDir . $basename . 'coverage',
550
            'job'      => $baseDir . $basename . 'php',
551
        ];
552
    }
553
554
    private function renderForCoverage(string &$job): void
555
    {
556
        $files = $this->getCoverageFiles();
557
558
        $template = new Template(
559
            __DIR__ . '/../Util/PHP/Template/PhptTestCase.tpl'
560
        );
561
562
        $composerAutoload = '\'\'';
563
564
        if (\defined('PHPUNIT_COMPOSER_INSTALL') && !\defined('PHPUNIT_TESTSUITE')) {
565
            $composerAutoload = \var_export(PHPUNIT_COMPOSER_INSTALL, true);
566
        }
567
568
        $phar = '\'\'';
569
570
        if (\defined('__PHPUNIT_PHAR__')) {
571
            $phar = \var_export(__PHPUNIT_PHAR__, true);
572
        }
573
574
        $globals = '';
575
576
        if (!empty($GLOBALS['__PHPUNIT_BOOTSTRAP'])) {
577
            $globals = '$GLOBALS[\'__PHPUNIT_BOOTSTRAP\'] = ' . \var_export(
578
                $GLOBALS['__PHPUNIT_BOOTSTRAP'],
579
                true
580
            ) . ";\n";
581
        }
582
583
        $template->setVar(
584
            [
585
                'composerAutoload' => $composerAutoload,
586
                'phar'             => $phar,
587
                'globals'          => $globals,
588
                'job'              => $files['job'],
589
                'coverageFile'     => $files['coverage'],
590
            ]
591
        );
592
593
        \file_put_contents($files['job'], $job);
594
        $job = $template->render();
595
    }
596
597
    private function cleanupForCoverage(): array
598
    {
599
        $coverage = [];
600
        $files    = $this->getCoverageFiles();
601
602
        if (\file_exists($files['coverage'])) {
603
            $buffer = @\file_get_contents($files['coverage']);
604
605
            if ($buffer !== false) {
606
                $coverage = @\unserialize($buffer);
607
608
                if ($coverage === false) {
609
                    $coverage = [];
610
                }
611
            }
612
        }
613
614
        foreach ($files as $file) {
615
            @\unlink($file);
616
        }
617
618
        return $coverage;
619
    }
620
621
    private function stringifyIni(array $ini): array
622
    {
623
        $settings = [];
624
625
        foreach ($ini as $key => $value) {
626
            if (\is_array($value)) {
627
                foreach ($value as $val) {
628
                    $settings[] = $key . '=' . $val;
629
                }
630
631
                continue;
632
            }
633
634
            $settings[] = $key . '=' . $value;
635
        }
636
637
        return $settings;
638
    }
639
640
    private function getLocationHintFromDiff(string $message, array $sections): array
641
    {
642
        $needle       = '';
643
        $previousLine = '';
644
        $block        = 'message';
645
646
        foreach (\preg_split('/\r\n|\r|\n/', $message) as $line) {
647
            $line = \trim($line);
648
649
            if ($block === 'message' && $line === '--- Expected') {
650
                $block = 'expected';
651
            }
652
653
            if ($block === 'expected' && $line === '@@ @@') {
654
                $block = 'diff';
655
            }
656
657
            if ($block === 'diff') {
658
                if (\strpos($line, '+') === 0) {
659
                    $needle = $this->getCleanDiffLine($previousLine);
660
661
                    break;
662
                }
663
664
                if (\strpos($line, '-') === 0) {
665
                    $needle = $this->getCleanDiffLine($line);
666
667
                    break;
668
                }
669
            }
670
671
            if (!empty($line)) {
672
                $previousLine = $line;
673
            }
674
        }
675
676
        return $this->getLocationHint($needle, $sections);
677
    }
678
679
    private function getCleanDiffLine(string $line): string
680
    {
681
        if (\preg_match('/^[\-+]([\'\"]?)(.*)\1$/', $line, $matches)) {
682
            $line = $matches[2];
683
        }
684
685
        return $line;
686
    }
687
688
    private function getLocationHint(string $needle, array $sections, ?string $sectionName = null): array
689
    {
690
        $needle = \trim($needle);
691
692
        if (empty($needle)) {
693
            return [[
694
                'file' => \realpath($this->filename),
695
                'line' => 1,
696
            ]];
697
        }
698
699
        if ($sectionName) {
700
            $search = [$sectionName];
701
        } else {
702
            $search = [
703
                // 'FILE',
704
                'EXPECT',
705
                'EXPECTF',
706
                'EXPECTREGEX',
707
            ];
708
        }
709
710
        foreach ($search as $section) {
711
            if (!isset($sections[$section])) {
712
                continue;
713
            }
714
715
            if (isset($sections[$section . '_EXTERNAL'])) {
716
                $externalFile = \trim($sections[$section . '_EXTERNAL']);
717
718
                return [
719
                    [
720
                        'file' => \realpath(\dirname($this->filename) . \DIRECTORY_SEPARATOR . $externalFile),
721
                        'line' => 1,
722
                    ],
723
                    [
724
                        'file' => \realpath($this->filename),
725
                        'line' => ($sections[$section . '_EXTERNAL_offset'] ?? 0) + 1,
726
                    ],
727
                ];
728
            }
729
730
            $sectionOffset = $sections[$section . '_offset'] ?? 0;
731
            $offset        = $sectionOffset + 1;
732
733
            foreach (\preg_split('/\r\n|\r|\n/', $sections[$section]) as $line) {
734
                if (\strpos($line, $needle) !== false) {
735
                    return [[
736
                        'file' => \realpath($this->filename),
737
                        'line' => $offset,
738
                    ]];
739
                }
740
                $offset++;
741
            }
742
        }
743
744
        if ($sectionName) {
745
            // String not found in specified section, show user the start of the named section
746
            return [[
747
                'file' => \realpath($this->filename),
748
                'line' => $sectionOffset,
749
            ]];
750
        }
751
752
        // No section specified, show user start of code
753
        return [[
754
            'file' => \realpath($this->filename),
755
            'line' => 1,
756
        ]];
757
    }
758
}
759