Completed
Push — master ( 5e1af6...5ef5a6 )
by Jonathan
02:43
created

Run::initialize()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 14
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 14
ccs 12
cts 12
cp 1
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 11
nc 2
nop 2
crap 2
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 null|ProgressBar
80
     */
81
    private $progressBar;
82
83
    /**
84
     * @var integer
85
     */
86
    private $numChunks = 1;
87
88
    /**
89
     * @var null|integer
90
     */
91
    private $chunk = null;
92
93
    /**
94
     * @var ChunkedTests
95
     */
96
    private $chunkedTests;
97
98
    /**
99
     * @var integer
100
     */
101
    private $numParallelProcesses = 1;
102
103
    /**
104
     * @var integer
105
     */
106
    private $numAssertions = 0;
107
108
    /**
109
     * @var integer
110
     */
111
    private $numFailures = 0;
112
113
    /**
114
     * @var integer
115
     */
116
    private $numChunkFailures = 0;
117
118
    /**
119
     * @var integer
120
     */
121
    private $totalTestsRan = 0;
122
123
    /**
124
     * @var array
125
     */
126
    private $codes = [];
127
128
    /**
129
     * @var array
130
     */
131
    private $processes = [];
132
133 1
    public function __construct(
134
        TestRunner $testRunner,
135
        Configuration $configuration,
136
        TestChunker $testChunker,
137
        TestFinder $testFinder)
138
    {
139 1
        $this->testRunner = $testRunner;
140 1
        $this->configuration = $configuration;
141 1
        $this->testChunker = $testChunker;
142 1
        $this->testFinder = $testFinder;
143 1
    }
144
145
    public function getName() : string
146
    {
147
        return self::NAME;
148
    }
149
150
    public function configure(Command $command)
151
    {
152
        $command
153
            ->setDescription('Run tests.')
154
            ->addOption('debug', null, InputOption::VALUE_NONE, 'Run tests in debug mode.')
155
            ->addOption('memory-limit', null, InputOption::VALUE_REQUIRED, 'Memory limit for PHP.')
156
            ->addOption('stop', null, InputOption::VALUE_NONE, 'Stop on failure or error.')
157
            ->addOption('failed', null, InputOption::VALUE_NONE, 'Track tests that have failed.')
158
            ->addOption('create-dbs', null, InputOption::VALUE_NONE, 'Create the test databases before running tests.')
159
            ->addOption('sandbox', null, InputOption::VALUE_NONE, 'Configure unique names.')
160
            ->addOption('chunk', null, InputOption::VALUE_REQUIRED, 'Run a specific chunk of tests.')
161
            ->addOption('num-chunks', null, InputOption::VALUE_REQUIRED, 'The number of chunks to run tests in.')
162
            ->addOption('group', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run all tests in these groups.')
163
            ->addOption('exclude-group', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run all tests excluding these groups.')
164
            ->addOption('changed', null, InputOption::VALUE_NONE, 'Run changed tests.')
165
            ->addOption('parallel', null, InputOption::VALUE_REQUIRED, 'Run test chunks in parallel.')
166
            ->addOption('filter', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter tests by path/file name and run them.')
167
            ->addOption('contains', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run tests that match the given content.')
168
            ->addOption('not-contains', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run tests that do not match the given content.')
169
            ->addOption('file', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run test file.')
170
            ->addOption('phpunit-opt', null, InputOption::VALUE_REQUIRED, 'Pass through phpunit options.')
171
        ;
172
    }
173
174 1
    public function execute(InputInterface $input, OutputInterface $output)
175
    {
176 1
        $this->initialize($input, $output);
177
178 1
        $this->chunkedTests = $this->chunkTestFiles(
179 1
            $this->findTestFiles()
180
        );
181
182 1
        if (!$this->chunkedTests->hasTests()) {
183
            $this->output->writeln('<error>No tests found to run.</error>');
184
185
            return;
186
        }
187
188 1
        $this->outputHeader();
189
190 1
        $this->setupSandbox();
191
192 1
        $this->runChunks();
193
194 1
        $this->outputFooter();
195
196 1
        return $this->hasFailed() ? 1 : 0;
197
    }
198
199 1
    private function initialize(InputInterface $input, OutputInterface $output)
200
    {
201 1
        $this->stopwatch = new Stopwatch();
202 1
        $this->stopwatch->start('Tests');
203
204 1
        $this->input = $input;
205 1
        $this->output = $output;
206 1
        $this->verbose = $output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE;
207 1
        $this->numParallelProcesses = (int) $this->input->getOption('parallel');
208 1
        $this->parallel = $this->numParallelProcesses > 1;
209 1
        $this->showProgressBar = !$this->verbose && !$this->parallel;
210 1
        $this->numChunks = $this->getNumChunks();
211 1
        $this->chunk = (int) $this->input->getOption('chunk');
212 1
    }
213
214 1
    private function getNumChunks() : int
215
    {
216 1
        return (int) $this->input->getOption('num-chunks')
217 1
            ?: $this->configuration->getNumChunks() ?: 1;
218
    }
219
220 1
    private function findTestFiles()
221
    {
222 1
        $files = $this->input->getOption('file');
223
224 1
        if (!empty($files)) {
225
            return $files;
226
        }
227
228 1
        $groups = $this->input->getOption('group');
229 1
        $excludeGroups = $this->input->getOption('exclude-group');
230 1
        $changed = $this->input->getOption('changed');
231 1
        $filters = $this->input->getOption('filter');
232 1
        $contains = $this->input->getOption('contains');
233 1
        $notContains = $this->input->getOption('not-contains');
234
235 1
        $this->testFinder
236 1
            ->inGroups($groups)
237 1
            ->notInGroups($excludeGroups)
238 1
            ->changed($changed)
239
        ;
240
241 1
        foreach ($filters as $filter) {
242
            $this->testFinder->filter($filter);
243
        }
244
245 1
        foreach ($contains as $contain) {
246
            $this->testFinder->contains($contain);
247
        }
248
249 1
        foreach ($notContains as $notContain) {
250
            $this->testFinder->notContains($notContain);
251
        }
252
253 1
        return $this->testFinder->getFiles();
254
    }
255
256 1
    private function chunkTestFiles(array $testFiles) : ChunkedTests
257
    {
258 1
        $chunkedTests = (new ChunkedTests())
259 1
            ->setNumChunks($this->numChunks)
260 1
            ->setChunk($this->chunk)
261
        ;
262
263 1
        if (empty($testFiles)) {
264
            return $chunkedTests;
265
        }
266
267 1
        $this->testChunker->chunkTestFiles($chunkedTests, $testFiles);
268
269 1
        return $chunkedTests;
270
    }
271
272 1
    private function outputHeader()
273
    {
274 1
        $chunks = $this->chunkedTests->getChunks();
275 1
        $testsPerChunk = $this->chunkedTests->getTestsPerChunk();
276 1
        $totalTests = $this->chunkedTests->getTotalTests();
277 1
        $numChunks = $this->chunkedTests->getNumChunks();
278
279 1
        $this->output->writeln(sprintf('Total Tests: <info>%s</info>', $totalTests));
280 1
        $this->output->writeln(sprintf('Number of Chunks Configured: <info>%s</info>', $numChunks));
281 1
        $this->output->writeln(sprintf('Number of Chunks Produced: <info>%s</info>', count($chunks)));
282 1
        $this->output->writeln(sprintf('Tests Per Chunk: <info>~%s</info>', $testsPerChunk));
283
284 1
        if ($chunk = $this->chunkedTests->getChunk()) {
285
            $this->output->writeln(sprintf('Chunk: <info>%s</info>', $chunk));
286
        }
287
288 1
        $this->output->writeln('-----------');
289 1
        $this->output->writeln('');
290 1
    }
291
292 1
    private function setupSandbox()
293
    {
294 1
        if ($this->input->getOption('sandbox')) {
295 1
            $this->testRunner->runTestCommand('sandbox');
296
        }
297 1
    }
298
299
    /**
300
     * @return null|integer
301
     */
302 1
    private function runChunks()
303
    {
304 1
        $chunks = $this->chunkedTests->getChunks();
305
306 1
        foreach ($chunks as $i => $chunk) {
307
            // drop and recreate dbs before running this chunk of tests
308
            if ($this->input->getOption('create-dbs')) {
309
                $this->testRunner->runTestCommand('create-dbs', [
310
                    '--quiet' => true,
311
                ]);
312
            }
313
314
            $chunkNum = $i + 1;
315
316
            $code = $this->runChunk($chunkNum, $chunk);
317
318
            if ($code > 0) {
319
                return $code;
320
            }
321
        }
322
323 1
        if ($this->parallel) {
324
            $this->waitForProcesses();
325
        }
326 1
    }
327
328
    private function runChunk(int $chunkNum, array $chunk)
329
    {
330
        $numTests = $this->countNumTestsInChunk($chunk);
331
332
        $this->totalTestsRan += $numTests;
333
334
        $process = $this->getChunkProcess($chunk);
335
        $this->processes[$chunkNum] = $process;
336
337
        $callback = $this->createProgressCallback($numTests);
338
339
        if ($this->parallel) {
340
            return $this->runChunkProcessParallel(
341
                $chunkNum, $process, $callback
342
            );
343
        }
344
345
        return $this->runChunkProcessSerial(
346
            $chunkNum, $process, $callback
347
        );
348
    }
349
350
    private function getChunkProcess(array $chunk) : Process
351
    {
352
        $files = $this->buildFilesFromChunk($chunk);
353
354
        $config = $this->testRunner->generatePhpunitXml($files);
355
356
        $command = sprintf('-c %s', $config);
357
358
        return $this->testRunner->getPhpunitProcess($command);
359
    }
360
361
    private function createProgressCallback(int $numTests) : Closure
362
    {
363
        if ($this->showProgressBar) {
364
            $this->progressBar = $this->createChunkProgressBar($numTests);
365
366
            return $this->createProgressBarCallback($this->progressBar);
367
        }
368
369
        if ($this->verbose) {
370
            return function($type, $out) {
371
                $this->extractDataFromPhpunitOutput($out);
372
373
                $this->output->write($out);
374
            };
375
        }
376
377
        return function($type, $out) {
378
            $this->extractDataFromPhpunitOutput($out);
379
        };
380
    }
381
382
    private function createProgressBarCallback(ProgressBar $progressBar)
383
    {
384
        return function(string $type, string $buffer) use ($progressBar) {
385
            $this->extractDataFromPhpunitOutput($buffer);
386
387
            if ($progressBar) {
388
                if (in_array($buffer, ['F', 'E'])) {
389
                    $progressBar->setBarCharacter('<fg=red>=</>');
390
                }
391
392
                if (in_array($buffer, ['F', 'E', 'S', '.'])) {
393
                    $progressBar->advance();
394
                }
395
            }
396
        };
397
    }
398
399
    private function runChunkProcessParallel(
400
        int $chunkNum,
401
        Process $process,
402
        Closure $callback)
403
    {
404
        $this->output->writeln(sprintf('Starting chunk <info>#%s</info>', $chunkNum));
405
406
        $process->start($callback);
407
408
        if (count($this->processes) >= $this->numParallelProcesses) {
409
            $this->waitForProcesses();
410
        }
411
    }
412
413
    private function waitForProcesses()
414
    {
415
        while (count($this->processes)) {
416
            foreach ($this->processes as $chunkNum => $process) {
417
                if ($process->isRunning()) {
418
                    continue;
419
                }
420
421
                unset($this->processes[$chunkNum]);
422
423
                $this->codes[] = $code = $process->getExitCode();
424
425
                if ($code > 0) {
426
                    $this->numChunkFailures++;
427
428
                    $this->output->writeln(sprintf('Chunk #%s <error>FAILED</error>', $chunkNum));
429
430
                    $this->output->writeln('');
431
                    $this->output->write($process->getOutput());
432
433
                    if ($this->input->getOption('stop')) {
434
                        return $code;
435
                    }
436 View Code Duplication
                } else {
437
                    $this->output->writeln(sprintf('Chunk #%s <info>PASSED</info>', $chunkNum));
438
439
                    if ($this->verbose) {
440
                        $this->output->writeln('');
441
                        $this->output->write($process->getOutput());
442
                    }
443
                }
444
            }
445
        }
446
    }
447
448
    private function runChunkProcessSerial(
449
        int $chunkNum,
450
        Process $process,
451
        Closure $callback)
452
    {
453
        if ($this->verbose) {
454
            $this->output->writeln('');
455
            $this->output->writeln(sprintf('Running chunk <info>#%s</info>', $chunkNum));
456
        }
457
458
        $this->codes[] = $code = $process->run($callback);
459
460
        if ($code > 0) {
461
            $this->numChunkFailures++;
462
463
            if ($this->verbose) {
464
                $this->output->writeln(sprintf('Chunk #%s <error>FAILED</error>', $chunkNum));
465
            }
466
467
            if ($this->input->getOption('stop')) {
468
                $this->output->writeln('');
469
                $this->output->writeln($process->getOutput());
470
471
                return $code;
472
            }
473
        }
474
475
        if (!$this->verbose) {
476
            $this->progressBar->finish();
477
            $this->output->writeln('');
478
        }
479
480 View Code Duplication
        if ($code > 0) {
481
            $this->output->writeln('');
482
483
            if (!$this->verbose) {
484
                $this->output->writeln($process->getOutput());
485
            }
486
        }
487
    }
488
489 1
    private function outputFooter()
490
    {
491 1
        $chunks = $this->chunkedTests->getChunks();
492
493 1
        $failed = $this->hasFailed();
494
495 1
        $event = $this->stopwatch->stop('Tests');
496
497 1
        $this->output->writeln('');
498 1
        $this->output->writeln(sprintf('Time: %s seconds, Memory: %s',
499 1
            round($event->getDuration() / 1000, 2),
500 1
            $this->formatBytes($event->getMemory())
501
        ));
502
503 1
        $this->output->writeln('');
504 1
        $this->output->writeln(sprintf('%s (%s chunks, %s tests, %s assertions, %s failures%s)',
505 1
            $failed ? '<error>FAILED</error>' : '<info>PASSED</info>',
506
            count($chunks),
507 1
            $this->totalTestsRan,
508 1
            $this->numAssertions,
509 1
            $this->numFailures,
510 1
            $failed ? sprintf(', Failed chunks: %s', $this->numChunkFailures) : ''
511
        ));
512 1
    }
513
514 1
    private function hasFailed() : bool
515
    {
516 1
        return array_sum($this->codes) ? true : false;
517
    }
518
519
    private function extractDataFromPhpunitOutput(string $outputBuffer) : int
520
    {
521
        preg_match_all('/([0-9]+) assertions/', $outputBuffer, $matches);
522
523 View Code Duplication
        if (isset($matches[1][0])) {
524
            $this->numAssertions += (int) $matches[1][0];
525
        }
526
527
        preg_match_all('/Assertions: ([0-9]+)/', $outputBuffer, $matches);
528
529 View Code Duplication
        if (isset($matches[1][0])) {
530
            $this->numAssertions += (int) $matches[1][0];
531
        }
532
533
        preg_match_all('/Failures: ([0-9]+)/', $outputBuffer, $matches);
534
535
        if (isset($matches[1][0])) {
536
            $this->numFailures += (int) $matches[1][0];
537
        }
538
539
        return 0;
540
    }
541
542
    private function countNumTestsInChunk(array $chunk) : int
543
    {
544
        return array_sum(array_map(function(array $chunkFile) {
545
            return $chunkFile['numTests'];
546
        }, $chunk));
547
    }
548
549
    private function buildFilesFromChunk(array $chunk) : array
550
    {
551
        return array_map(function(array $chunkFile) {
552
            return $chunkFile['file'];
553
        }, $chunk);
554
    }
555
556
    private function createChunkProgressBar(int $numTests) : ProgressBar
557
    {
558
        $progressBar = new ProgressBar($this->output, $numTests);
559
        $progressBar->setBarCharacter('<fg=green>=</>');
560
        $progressBar->setProgressCharacter("\xF0\x9F\x8C\xAD");
561
562
        return $progressBar;
563
    }
564
565 1
    private function formatBytes(int $size, int $precision = 2) : string
566
    {
567 1
        if (!$size) {
568
            return 0;
569
        }
570
571 1
        $base = log($size, 1024);
572 1
        $suffixes = ['', 'KB', 'MB', 'GB', 'TB'];
573
574 1
        return round(pow(1024, $base - floor($base)), $precision).$suffixes[floor($base)];
575
    }
576
}
577