Completed
Push — master ( bbf7c1...ea80b4 )
by Jonathan
02:40
created

Run::extractDataFromPhpunitOutput()   B

Complexity

Conditions 4
Paths 8

Size

Total Lines 22
Code Lines 11

Duplication

Lines 6
Ratio 27.27 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 0
Metric Value
dl 6
loc 22
ccs 0
cts 11
cp 0
rs 8.9197
c 0
b 0
f 0
cc 4
eloc 11
nc 8
nop 1
crap 20
1
<?php
2
3
declare(strict_types = 1);
4
5
namespace PHPChunkit\Command;
6
7
use Closure;
8
use PHPChunkit\ChunkedTests;
9
use PHPChunkit\TestChunker;
10
use PHPChunkit\TestFinder;
11
use PHPChunkit\TestRunner;
12
use PHPChunkit\Configuration;
13
use Symfony\Component\Console\Command\Command;
14
use Symfony\Component\Console\Helper\ProgressBar;
15
use Symfony\Component\Console\Input\InputInterface;
16
use Symfony\Component\Console\Input\InputOption;
17
use Symfony\Component\Console\Output\OutputInterface;
18
use Symfony\Component\Process\Process;
19
use Symfony\Component\Stopwatch\Stopwatch;
20
21
/**
22
 * @testClass PHPChunkit\Test\Command\RunTest
23
 */
24
class Run implements CommandInterface
25
{
26
    const NAME = 'run';
27
28
    /**
29
     * @var TestRunner
30
     */
31
    private $testRunner;
32
33
    /**
34
     * @var Configuration
35
     */
36
    private $configuration;
37
38
    /**
39
     * @var TestChunker
40
     */
41
    private $testChunker;
42
43
    /**
44
     * @var TestFinder
45
     */
46
    private $testFinder;
47
48
    /**
49
     * @var InputInterface
50
     */
51
    private $input;
52
53
    /**
54
     * @var OutputInterface
55
     */
56
    private $output;
57
58
    /**
59
     * @var Stopwatch
60
     */
61
    private $stopwatch;
62
63
    /**
64
     * @var bool
65
     */
66
    private $verbose = false;
67
68
    /**
69
     * @var bool
70
     */
71
    private $parallel = false;
72
73
    /**
74
     * @var bool
75
     */
76
    private $showProgressBar = true;
77
78
    /**
79
     * @var integer
80
     */
81
    private $numChunks = 1;
82
83
    /**
84
     * @var null|integer
85
     */
86
    private $chunk = null;
87
88
    /**
89
     * @var ChunkedTests
90
     */
91
    private $chunkedTests;
92
93
    /**
94
     * @var integer
95
     */
96
    private $numParallelProcesses = 1;
97
98
    /**
99
     * @var integer
100
     */
101
    private $numAssertions = 0;
102
103
    /**
104
     * @var integer
105
     */
106
    private $numFailures = 0;
107
108
    /**
109
     * @var integer
110
     */
111
    private $numChunkFailures = 0;
112
113
    /**
114
     * @var integer
115
     */
116
    private $totalTestsRan = 0;
117
118
    /**
119
     * @var array
120
     */
121
    private $codes = [];
122
123
    /**
124
     * @var array
125
     */
126
    private $processes = [];
127
128 1
    public function __construct(
129
        TestRunner $testRunner,
130
        Configuration $configuration,
131
        TestChunker $testChunker,
132
        TestFinder $testFinder)
133
    {
134 1
        $this->testRunner = $testRunner;
135 1
        $this->configuration = $configuration;
136 1
        $this->testChunker = $testChunker;
137 1
        $this->testFinder = $testFinder;
138 1
    }
139
140
    public function getName() : string
141
    {
142
        return self::NAME;
143
    }
144
145
    public function configure(Command $command)
146
    {
147
        $command
148
            ->setDescription('Run tests.')
149
            ->addOption('debug', null, InputOption::VALUE_NONE, 'Run tests in debug mode.')
150
            ->addOption('memory-limit', null, InputOption::VALUE_REQUIRED, 'Memory limit for PHP.')
151
            ->addOption('stop', null, InputOption::VALUE_NONE, 'Stop on failure or error.')
152
            ->addOption('failed', null, InputOption::VALUE_NONE, 'Track tests that have failed.')
153
            ->addOption('create-dbs', null, InputOption::VALUE_NONE, 'Create the test databases before running tests.')
154
            ->addOption('sandbox', null, InputOption::VALUE_NONE, 'Configure unique names.')
155
            ->addOption('chunk', null, InputOption::VALUE_REQUIRED, 'Run a specific chunk of tests.')
156
            ->addOption('num-chunks', null, InputOption::VALUE_REQUIRED, 'The number of chunks to run tests in.')
157
            ->addOption('group', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run all tests in these groups.')
158
            ->addOption('exclude-group', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run all tests excluding these groups.')
159
            ->addOption('changed', null, InputOption::VALUE_NONE, 'Run changed tests.')
160
            ->addOption('parallel', null, InputOption::VALUE_REQUIRED, 'Run test chunks in parallel.')
161
            ->addOption('filter', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter tests by path/file name and run them.')
162
            ->addOption('contains', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run tests that match the given content.')
163
            ->addOption('not-contains', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run tests that do not match the given content.')
164
            ->addOption('file', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run test file.')
165
            ->addOption('phpunit-opt', null, InputOption::VALUE_REQUIRED, 'Pass through phpunit options.')
166
        ;
167
    }
168
169 1
    public function execute(InputInterface $input, OutputInterface $output)
170
    {
171 1
        $this->initialize($input, $output);
172
173 1
        $this->chunkedTests = $this->chunkTestFiles(
174 1
            $this->findTestFiles()
175
        );
176
177 1
        if (!$this->chunkedTests->hasTests()) {
178
            $this->output->writeln('<error>No tests found to run.</error>');
179
180
            return;
181
        }
182
183 1
        $this->outputHeader();
184
185 1
        $this->setupSandbox();
186
187 1
        $this->runChunks();
188
189 1
        $this->outputFooter();
190
191 1
        return $this->hasFailed() ? 1 : 0;
192
    }
193
194 1
    private function initialize(InputInterface $input, OutputInterface $output)
195
    {
196 1
        $this->stopwatch = new Stopwatch();
197 1
        $this->stopwatch->start('Tests');
198
199 1
        $this->input = $input;
200 1
        $this->output = $output;
201 1
        $this->verbose = $output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE;
202 1
        $this->parallel = (bool) $this->input->getOption('parallel');
203 1
        $this->showProgressBar = !$this->verbose && !$this->parallel;
204 1
        $this->numChunks = $this->getNumChunks();
205 1
        $this->chunk = (int) $this->input->getOption('chunk');
206 1
    }
207
208 1
    private function getNumChunks() : int
209
    {
210 1
        return (int) $this->input->getOption('num-chunks')
211 1
            ?: $this->configuration->getNumChunks() ?: 1;
212
    }
213
214 1
    private function findTestFiles()
215
    {
216 1
        $files = $this->input->getOption('file');
217
218 1
        if (!empty($files)) {
219
            return $files;
220
        }
221
222 1
        $groups = $this->input->getOption('group');
223 1
        $excludeGroups = $this->input->getOption('exclude-group');
224 1
        $changed = $this->input->getOption('changed');
225 1
        $filters = $this->input->getOption('filter');
226 1
        $contains = $this->input->getOption('contains');
227 1
        $notContains = $this->input->getOption('not-contains');
228
229 1
        $this->testFinder
230 1
            ->inGroups($groups)
231 1
            ->notInGroups($excludeGroups)
232 1
            ->changed($changed)
233
        ;
234
235 1
        foreach ($filters as $filter) {
236
            $this->testFinder->filter($filter);
237
        }
238
239 1
        foreach ($contains as $contain) {
240
            $this->testFinder->contains($contain);
241
        }
242
243 1
        foreach ($notContains as $notContain) {
244
            $this->testFinder->notContains($notContain);
245
        }
246
247 1
        return $this->testFinder->getFiles();
248
    }
249
250 1
    private function chunkTestFiles(array $testFiles) : ChunkedTests
251
    {
252 1
        $chunkedTests = (new ChunkedTests())
253 1
            ->setNumChunks($this->numChunks)
254 1
            ->setChunk($this->chunk)
255
        ;
256
257 1
        if (!$testFiles) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $testFiles of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
258
            return $chunkedTests;
259
        }
260
261 1
        $this->testChunker->chunkTestFiles($chunkedTests, $testFiles);
262
263 1
        return $chunkedTests;
264
    }
265
266 1
    private function outputHeader()
267
    {
268 1
        $chunks = $this->chunkedTests->getChunks();
269 1
        $testsPerChunk = $this->chunkedTests->getTestsPerChunk();
270 1
        $totalTests = $this->chunkedTests->getTotalTests();
271 1
        $numChunks = $this->chunkedTests->getNumChunks();
272
273 1
        $this->output->writeln(sprintf('Total Tests: <info>%s</info>', $totalTests));
274 1
        $this->output->writeln(sprintf('Number of Chunks Configured: <info>%s</info>', $numChunks));
275 1
        $this->output->writeln(sprintf('Number of Chunks Produced: <info>%s</info>', count($chunks)));
276 1
        $this->output->writeln(sprintf('Tests Per Chunk: <info>~%s</info>', $testsPerChunk));
277
278 1
        if ($chunk = $this->chunkedTests->getChunk()) {
279
            $this->output->writeln(sprintf('Chunk: <info>%s</info>', $chunk));
280
        }
281
282 1
        $this->output->writeln('-----------');
283 1
        $this->output->writeln('');
284 1
    }
285
286 1
    private function setupSandbox()
287
    {
288 1
        if ($this->input->getOption('sandbox')) {
289 1
            $this->testRunner->runTestCommand('sandbox');
290
        }
291 1
    }
292
293
    /**
294
     * @return null|integer
295
     */
296 1
    private function runChunks()
297
    {
298 1
        $chunks = $this->chunkedTests->getChunks();
299
300 1
        foreach ($chunks as $i => $chunk) {
301
            // drop and recreate dbs before running this chunk of tests
302
            if ($this->input->getOption('create-dbs')) {
303
                $this->testRunner->runTestCommand('create-dbs', [
304
                    '--quiet' => true,
305
                ]);
306
            }
307
308
            $chunkNum = $i + 1;
309
310
            $code = $this->runChunk($chunkNum, $chunk);
311
312
            if ($code > 0) {
313
                return $code;
314
            }
315
        }
316
317 1
        if ($this->parallel) {
318
            $this->waitForProcesses();
319
        }
320 1
    }
321
322
    private function runChunk(int $chunkNum, array $chunk)
323
    {
324
        $numTests = $this->countNumTestsInChunk($chunk);
325
326
        $this->totalTestsRan += $numTests;
327
328
        $process = $this->getChunkProcess($chunk);
329
        $this->processes[$chunkNum] = $process;
330
331
        $callback = $this->createProgressCallback($numTests);
332
333
        if ($this->parallel) {
334
            return $this->runChunkProcessParallel(
335
                $chunkNum, $process, $callback
336
            );
337
        }
338
339
        return $this->runChunkProcessSerial(
340
            $chunkNum, $process, $callback
341
        );
342
    }
343
344
    private function getChunkProcess(array $chunk) : Process
345
    {
346
        $files = $this->buildFilesFromChunk($chunk);
347
348
        $config = $this->testRunner->generatePhpunitXml($files);
349
350
        $command = sprintf('-c %s', $config);
351
352
        return $this->testRunner->getPhpunitProcess($command);
353
    }
354
355
    private function createProgressCallback(int $numTests) : Closure
356
    {
357
        if ($this->showProgressBar) {
358
            $this->progressBar = $this->createChunkProgressBar($numTests);
0 ignored issues
show
Bug introduced by
The property progressBar does not seem to exist. Did you mean showProgressBar?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
359
360
            return $this->createProgressBarCallback($this->progressBar);
0 ignored issues
show
Bug introduced by
The property progressBar does not seem to exist. Did you mean showProgressBar?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
361
        }
362
363
        if ($this->verbose) {
364
            return function($type, $out) {
365
                $this->extractDataFromPhpunitOutput($out);
366
367
                $this->output->write($out);
368
            };
369
        }
370
371
        return function($type, $out) {
372
            $this->extractDataFromPhpunitOutput($out);
373
        };
374
    }
375
376
    private function createProgressBarCallback(ProgressBar $progressBar)
377
    {
378
        return function(string $type, string $buffer) use ($progressBar) {
379
            $this->extractDataFromPhpunitOutput($buffer);
380
381
            if ($progressBar) {
382
                if (in_array($buffer, ['F', 'E'])) {
383
                    $progressBar->setBarCharacter('<fg=red>=</>');
384
                }
385
386
                if (in_array($buffer, ['F', 'E', 'S', '.'])) {
387
                    $progressBar->advance();
388
                }
389
            }
390
        };
391
    }
392
393
    private function runChunkProcessParallel(
394
        int $chunkNum,
395
        Process $process,
396
        Closure $callback)
397
    {
398
        $this->output->writeln(sprintf('Starting chunk <info>#%s</info>', $chunkNum));
399
400
        $process->start($callback);
401
402
        if (count($this->processes) >= $this->numParallelProcesses) {
403
            $this->waitForProcesses();
404
        }
405
    }
406
407
    private function waitForProcesses()
408
    {
409
        while (count($this->processes)) {
410
            foreach ($this->processes as $chunkNum => $process) {
411
                if ($process->isRunning()) {
412
                    continue;
413
                }
414
415
                unset($this->processes[$chunkNum]);
416
417
                $this->codes[] = $code = $process->getExitCode();
418
419
                if ($code > 0) {
420
                    $this->numChunkFailures++;
421
422
                    $this->output->writeln(sprintf('Chunk #%s <error>FAILED</error>', $chunkNum));
423
424
                    $this->output->writeln('');
425
                    $this->output->write($process->getOutput());
426
427
                    if ($this->input->getOption('stop')) {
428
                        return $code;
429
                    }
430 View Code Duplication
                } else {
1 ignored issue
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
431
                    $this->output->writeln(sprintf('Chunk #%s <info>PASSED</info>', $chunkNum));
432
433
                    if ($this->verbose) {
434
                        $this->output->writeln('');
435
                        $this->output->write($process->getOutput());
436
                    }
437
                }
438
            }
439
        }
440
    }
441
442
    private function runChunkProcessSerial(
443
        int $chunkNum,
444
        Process $process,
445
        Closure $callback)
446
    {
447
        if ($this->verbose) {
448
            $this->output->writeln('');
449
            $this->output->writeln(sprintf('Running chunk <info>#%s</info>', $chunkNum));
450
        }
451
452
        $this->codes[] = $code = $process->run($callback);
453
454
        if ($code > 0) {
455
            $this->numChunkFailures++;
456
457
            if ($this->verbose) {
458
                $this->output->writeln(sprintf('Chunk #%s <error>FAILED</error>', $chunkNum));
459
            }
460
461
            if ($this->input->getOption('stop')) {
462
                $this->output->writeln('');
463
                $this->output->writeln($process->getOutput());
464
465
                return $code;
466
            }
467
        }
468
469
        if (!$this->verbose) {
470
            $this->progressBar->finish();
0 ignored issues
show
Bug introduced by
The property progressBar does not seem to exist. Did you mean showProgressBar?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
471
            $this->output->writeln('');
472
        }
473
474 View Code Duplication
        if ($code > 0) {
1 ignored issue
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
475
            $this->output->writeln('');
476
477
            if (!$this->verbose) {
478
                $this->output->writeln($process->getOutput());
479
            }
480
        }
481
    }
482
483 1
    private function outputFooter()
484
    {
485 1
        $chunks = $this->chunkedTests->getChunks();
486
487 1
        $failed = $this->hasFailed();
488
489 1
        $event = $this->stopwatch->stop('Tests');
490
491 1
        $this->output->writeln('');
492 1
        $this->output->writeln(sprintf('Time: %s seconds, Memory: %s',
493 1
            round($event->getDuration() / 1000, 2),
494 1
            $this->formatBytes($event->getMemory())
495
        ));
496
497 1
        $this->output->writeln('');
498 1
        $this->output->writeln(sprintf('%s (%s chunks, %s tests, %s assertions, %s failures%s)',
499 1
            $failed ? '<error>FAILED</error>' : '<info>PASSED</info>',
500
            count($chunks),
501 1
            $this->totalTestsRan,
502 1
            $this->numAssertions,
503 1
            $this->numFailures,
504 1
            $failed ? sprintf(', Failed chunks: %s', $this->numChunkFailures) : ''
505
        ));
506 1
    }
507
508 1
    private function hasFailed() : bool
509
    {
510 1
        return array_sum($this->codes) ? true : false;
511
    }
512
513
    private function extractDataFromPhpunitOutput(string $outputBuffer) : int
514
    {
515
        preg_match_all('/([0-9]+) assertions/', $outputBuffer, $matches);
516
517 View Code Duplication
        if (isset($matches[1][0])) {
1 ignored issue
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
518
            $this->numAssertions += (int) $matches[1][0];
519
        }
520
521
        preg_match_all('/Assertions: ([0-9]+)/', $outputBuffer, $matches);
522
523 View Code Duplication
        if (isset($matches[1][0])) {
1 ignored issue
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
524
            $this->numAssertions += (int) $matches[1][0];
525
        }
526
527
        preg_match_all('/Failures: ([0-9]+)/', $outputBuffer, $matches);
528
529
        if (isset($matches[1][0])) {
530
            $this->numFailures += (int) $matches[1][0];
531
        }
532
533
        return 0;
534
    }
535
536
    private function countNumTestsInChunk(array $chunk) : int
537
    {
538
        return array_sum(array_map(function(array $chunkFile) {
539
            return $chunkFile['numTests'];
540
        }, $chunk));
541
    }
542
543
    private function buildFilesFromChunk(array $chunk) : array
544
    {
545
        return array_map(function(array $chunkFile) {
546
            return $chunkFile['file'];
547
        }, $chunk);
548
    }
549
550
    private function createChunkProgressBar(int $numTests) : ProgressBar
551
    {
552
        $progressBar = new ProgressBar($this->output, $numTests);
553
        $progressBar->setBarCharacter('<fg=green>=</>');
554
        $progressBar->setProgressCharacter("\xF0\x9F\x8C\xAD");
555
556
        return $progressBar;
557
    }
558
559 1
    private function formatBytes(int $size, int $precision = 2) : string
560
    {
561 1
        if (!$size) {
562
            return 0;
563
        }
564
565 1
        $base = log($size, 1024);
566 1
        $suffixes = ['', 'KB', 'MB', 'GB', 'TB'];
567
568 1
        return round(pow(1024, $base - floor($base)), $precision).$suffixes[floor($base)];
569
    }
570
}
571