Completed
Push — master ( 7af375...bbf7c1 )
by Jonathan
02:38
created

Run::createChunkProgressBar()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 10
ccs 0
cts 5
cp 0
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 7
nc 1
nop 2
crap 2
1
<?php
2
3
declare(strict_types = 1);
4
5
namespace PHPChunkit\Command;
6
7
use PHPChunkit\ChunkedTests;
8
use PHPChunkit\TestChunker;
9
use PHPChunkit\TestFinder;
10
use PHPChunkit\TestRunner;
11
use PHPChunkit\Configuration;
12
use Symfony\Component\Console\Command\Command;
13
use Symfony\Component\Console\Helper\ProgressBar;
14
use Symfony\Component\Console\Input\InputInterface;
15
use Symfony\Component\Console\Input\InputOption;
16
use Symfony\Component\Console\Output\OutputInterface;
17
use Symfony\Component\Process\Process;
18
use Symfony\Component\Stopwatch\Stopwatch;
19
20
/**
21
 * @testClass PHPChunkit\Test\Command\RunTest
22
 */
23
class Run implements CommandInterface
24
{
25
    const NAME = 'run';
26
27
    /**
28
     * @var TestRunner
29
     */
30
    private $testRunner;
31
32
    /**
33
     * @var Configuration
34
     */
35
    private $configuration;
36
37
    /**
38
     * @var TestChunker
39
     */
40
    private $testChunker;
41
42
    /**
43
     * @var TestFinder
44
     */
45
    private $testFinder;
46
47 1
    public function __construct(
48
        TestRunner $testRunner,
49
        Configuration $configuration,
50
        TestChunker $testChunker,
51
        TestFinder $testFinder)
52
    {
53 1
        $this->testRunner = $testRunner;
54 1
        $this->configuration = $configuration;
55 1
        $this->testChunker = $testChunker;
56 1
        $this->testFinder = $testFinder;
57 1
    }
58
59
    public function getName() : string
60
    {
61
        return self::NAME;
62
    }
63
64
    public function configure(Command $command)
65
    {
66
        $command
67
            ->setDescription('Run tests.')
68
            ->addOption('debug', null, InputOption::VALUE_NONE, 'Run tests in debug mode.')
69
            ->addOption('memory-limit', null, InputOption::VALUE_REQUIRED, 'Memory limit for PHP.')
70
            ->addOption('stop', null, InputOption::VALUE_NONE, 'Stop on failure or error.')
71
            ->addOption('failed', null, InputOption::VALUE_NONE, 'Track tests that have failed.')
72
            ->addOption('create-dbs', null, InputOption::VALUE_NONE, 'Create the test databases before running tests.')
73
            ->addOption('sandbox', null, InputOption::VALUE_NONE, 'Configure unique names.')
74
            ->addOption('chunk', null, InputOption::VALUE_REQUIRED, 'Run a specific chunk of tests.')
75
            ->addOption('num-chunks', null, InputOption::VALUE_REQUIRED, 'The number of chunks to run tests in.')
76
            ->addOption('group', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run all tests in these groups.')
77
            ->addOption('exclude-group', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run all tests excluding these groups.')
78
            ->addOption('changed', null, InputOption::VALUE_NONE, 'Run changed tests.')
79
            ->addOption('parallel', null, InputOption::VALUE_REQUIRED, 'Run test chunks in parallel.')
80
            ->addOption('filter', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter tests by path/file name and run them.')
81
            ->addOption('contains', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run tests that match the given content.')
82
            ->addOption('not-contains', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run tests that do not match the given content.')
83
            ->addOption('file', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Run test file.')
84
            ->addOption('phpunit-opt', null, InputOption::VALUE_REQUIRED, 'Pass through phpunit options.')
85
        ;
86
    }
87
88 1
    public function execute(InputInterface $input, OutputInterface $output)
89
    {
90 1
        $stopwatch = new Stopwatch();
91 1
        $stopwatch->start('Tests');
92
93 1
        $verbose = $output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE;
94 1
        $parallel = $input->getOption('parallel');
95 1
        $showProgressBar = !$verbose && !$parallel;
96
97 1
        $chunkedTests = $this->chunkTestFiles($input);
98
99 1
        $chunks = $chunkedTests->getChunks();
100 1
        $testsPerChunk = $chunkedTests->getTestsPerChunk();
101 1
        $totalTests = $chunkedTests->getTotalTests();
102 1
        $numChunks = $chunkedTests->getNumChunks();
103
104 1
        if (!$totalTests) {
105
            $output->writeln('<error>No tests found to run.</error>');
106
107
            return;
108
        }
109
110 1
        $output->writeln(sprintf('Total Tests: <info>%s</info>', $totalTests));
111 1
        $output->writeln(sprintf('Number of Chunks Configured: <info>%s</info>', $numChunks));
112 1
        $output->writeln(sprintf('Number of Chunks Produced: <info>%s</info>', count($chunks)));
113 1
        $output->writeln(sprintf('Tests Per Chunk: <info>~%s</info>', $testsPerChunk));
114
115 1
        if ($chunk = $chunkedTests->getChunk()) {
116
            $output->writeln(sprintf('Chunk: <info>%s</info>', $chunk));
117
        }
118
119 1
        $output->writeln('-----------');
120 1
        $output->writeln('');
121
122
        // sandbox this run
123 1
        if ($input->getOption('sandbox')) {
124 1
            $this->testRunner->runTestCommand('sandbox');
125
        }
126
127
        // environment vars
128 1
        $env = [];
129
130 1
        if (empty($chunks)) {
131 1
            $output->writeln('<error>No tests to run.</error>');
132
        }
133
134 1
        $codes = [];
135 1
        $processes = [];
136 1
        $numChunkFailures = 0;
137 1
        $totalTestsRan = 0;
138 1
        $numAssertions = 0;
139 1
        $numFailures = 0;
140 1
        $numProcesses = $parallel;
141
142 1
        foreach ($chunks as $i => $chunk) {
143
            $chunkNum = $i + 1;
144
145
            // drop and recreate dbs before running this chunk of tests
146
            if ($input->getOption('create-dbs')) {
147
                $this->testRunner->runTestCommand('create-dbs', [
148
                    '--quiet' => true,
149
                ]);
150
            }
151
152
            $numTests = $this->countNumTestsInChunk($chunk);
153
154
            $totalTestsRan += $numTests;
155
156
            $progressBar = $showProgressBar
157
                ? $this->createChunkProgressBar($output, $numTests)
158
                : null
159
            ;
160
161
            if ($showProgressBar) {
162
                $callback = $this->createProgressCallback($progressBar, $numAssertions, $numFailures);
163
            } else {
164
                if ($verbose) {
165
                    $callback = function($type, $out) use ($output, &$numAssertions, &$numFailures) {
166
                        $numAssertions += $this->extractNumAssertions($out);
167
                        $numFailures += $this->extractNumFailures($out);
168
169
                        $output->write($out);
170
                    };
171
                } else {
172
                    $callback = function($type, $out) use (&$numAssertions, &$numFailures) {
173
                        $numAssertions += $this->extractNumAssertions($out);
174
                        $numFailures += $this->extractNumFailures($out);
175
                    };
176
                }
177
            }
178
179
            $processes[$chunkNum] = $process = $this->getChunkProcess(
180
                $chunk, $env
181
            );
182
183
            if ($parallel) {
184
                $output->writeln(sprintf('Starting chunk <info>#%s</info>', $chunkNum));
185
186
                $process->start($callback);
187
188
                if (count($processes) >= $numProcesses) {
189
                    $this->waitForProcesses(
190
                        $processes,
191
                        $input,
192
                        $output,
193
                        $verbose,
194
                        $codes,
195
                        $numChunkFailures
196
                    );
197
                }
198
199
            } else {
200
                if ($verbose) {
201
                    $output->writeln('');
202
                    $output->writeln(sprintf('Running chunk <info>#%s</info>', $chunkNum));
203
                }
204
205
                $codes[] = $code = $process->run($callback);
206
207
                if ($code > 0) {
208
                    $numChunkFailures++;
209
210
                    if ($verbose) {
211
                        $output->writeln(sprintf('Chunk #%s <error>FAILED</error>', $chunkNum));
212
                    }
213
214
                    if ($input->getOption('stop')) {
215
                        $output->writeln('');
216
                        $output->writeln($process->getOutput());
217
218
                        return $code;
219
                    }
220
                }
221
222
                if (!$verbose) {
223
                    $progressBar->finish();
224
                    $output->writeln('');
225
                }
226
227
                if ($code > 0) {
228
                    $output->writeln('');
229
230
                    if (!$verbose) {
231
                        $output->writeln($process->getOutput());
232
                    }
233
                }
234
            }
235
        }
236
237 1
        if ($parallel) {
238
            $this->waitForProcesses(
239
                $processes,
240
                $input,
241
                $output,
242
                $verbose,
243
                $codes,
244
                $numChunkFailures
245
            );
246
        }
247
248 1
        $failed = array_sum($codes) ? true : false;
249
250 1
        $event = $stopwatch->stop('Tests');
251
252 1
        $output->writeln('');
253 1
        $output->writeln(sprintf('Time: %s seconds, Memory: %s',
254 1
            round($event->getDuration() / 1000, 2),
255 1
            $this->formatBytes($event->getMemory())
256
        ));
257
258 1
        $output->writeln('');
259 1
        $output->writeln(sprintf('%s (%s chunks, %s tests, %s assertions, %s failures%s)',
260 1
            $failed ? '<error>FAILED</error>' : '<info>PASSED</info>',
261
            count($chunks),
262
            $totalTestsRan,
263
            $numAssertions,
264
            $numFailures,
265 1
            $failed ? sprintf(', Failed chunks: %s', $numChunkFailures) : ''
266
        ));
267
268 1
        return $failed ? 1 : 0;
269
    }
270
271
    private function waitForProcesses(
272
        array &$processes,
273
        InputInterface $input,
274
        OutputInterface $output,
275
        bool $verbose,
276
        &$codes,
277
        &$numChunkFailures)
278
    {
279
        while (count($processes)) {
280
            foreach ($processes as $chunkNum => $process) {
281
                if ($process->isRunning()) {
282
                    continue;
283
                }
284
285
                unset($processes[$chunkNum]);
286
287
                $codes[] = $code = $process->getExitCode();
288
289
                if ($code > 0) {
290
                    $numChunkFailures++;
291
292
                    $output->writeln(sprintf('Chunk #%s <error>FAILED</error>', $chunkNum));
293
294
                    $output->writeln('');
295
                    $output->write($process->getOutput());
296
297
                    if ($input->getOption('stop')) {
298
                        return $code;
299
                    }
300
                } else {
301
                    $output->writeln(sprintf('Chunk #%s <info>PASSED</info>', $chunkNum));
302
303
                    if ($verbose) {
304
                        $output->writeln('');
305
                        $output->write($process->getOutput());
306
                    }
307
                }
308
            }
309
        }
310
    }
311
312
    private function extractNumAssertions(string $output) : int
313
    {
314
        preg_match_all('/([0-9]+) assertions/', $output, $matches);
315
316
        if (isset($matches[1][0])) {
317
            return (int) $matches[1][0];
318
        }
319
320
        preg_match_all('/Assertions: ([0-9]+)/', $output, $matches);
321
322
        if (isset($matches[1][0])) {
323
            return (int) $matches[1][0];
324
        }
325
326
        return 0;
327
    }
328
329
    private function extractNumFailures(string $output) : int
330
    {
331
        preg_match_all('/Failures: ([0-9]+)/', $output, $matches);
332
333
        if (isset($matches[1][0])) {
334
            return (int) $matches[1][0];
335
        }
336
337
        return 0;
338
    }
339
340 1
    private function formatBytes(int $size, int $precision = 2) : string
341
    {
342 1
        if (!$size) {
343
            return 0;
344
        }
345
346 1
        $base = log($size, 1024);
347 1
        $suffixes = ['', 'KB', 'MB', 'GB', 'TB'];
348
349 1
        return round(pow(1024, $base - floor($base)), $precision).$suffixes[floor($base)];
350
    }
351
352
    private function getChunkProcess(array $chunk, array $env) : Process
353
    {
354
        $command = $this->createChunkCommand($chunk);
355
356
        return $this->testRunner->getPhpunitProcess($command, $env);
357
    }
358
359 1
    private function chunkTestFiles(InputInterface $input) : ChunkedTests
360
    {
361 1
        $testFiles = $this->findTestFiles($input);
362
363 1
        $numChunks = (int) $input->getOption('num-chunks')
364 1
            ?: $this->configuration->getNumChunks() ?: 1;
365 1
        $chunk = (int) $input->getOption('chunk');
366
367 1
        $chunkedTests = (new ChunkedTests())
368 1
            ->setNumChunks($numChunks)
369 1
            ->setChunk($chunk)
370
        ;
371
372 1
        if (!$testFiles) {
373
            return $chunkedTests;
374
        }
375
376 1
        $this->testChunker->chunkTestFiles($chunkedTests, $testFiles);
377
378 1
        return $chunkedTests;
379
    }
380
381 1
    private function findTestFiles(InputInterface $input)
382
    {
383 1
        $files = $input->getOption('file');
384
385 1
        if (!empty($files)) {
386
            return $files;
387
        }
388
389 1
        $groups = $input->getOption('group');
390 1
        $excludeGroups = $input->getOption('exclude-group');
391 1
        $changed = $input->getOption('changed');
392 1
        $filters = $input->getOption('filter');
393 1
        $contains = $input->getOption('contains');
394 1
        $notContains = $input->getOption('not-contains');
395
396 1
        $this->testFinder
397 1
            ->inGroups($groups)
398 1
            ->notInGroups($excludeGroups)
399 1
            ->changed($changed)
400
        ;
401
402 1
        foreach ($filters as $filter) {
403
            $this->testFinder->filter($filter);
404
        }
405
406 1
        foreach ($contains as $contain) {
407
            $this->testFinder->contains($contain);
408
        }
409
410 1
        foreach ($notContains as $notContain) {
411
            $this->testFinder->notContains($notContain);
412
        }
413
414 1
        return $this->testFinder->getFiles();
415
    }
416
417
    private function createChunkCommand(array $chunk) : string
418
    {
419
        $files = $this->buildFilesFromChunk($chunk);
420
421
        $config = $this->testRunner->generatePhpunitXml($files);
422
423
        return sprintf('-c %s', $config);
424
    }
425
426
    private function countNumTestsInChunk(array $chunk) : int
427
    {
428
        return array_sum(array_map(function(array $chunkFile) {
429
            return $chunkFile['numTests'];
430
        }, $chunk));
431
    }
432
433
    private function buildFilesFromChunk(array $chunk) : array
434
    {
435
        return array_map(function(array $chunkFile) {
436
            return $chunkFile['file'];
437
        }, $chunk);
438
    }
439
440
    private function createChunkProgressBar(
441
        OutputInterface $output,
442
        int $numTests) : ProgressBar
443
    {
444
        $progressBar = new ProgressBar($output, $numTests);
445
        $progressBar->setBarCharacter('<fg=green>=</>');
446
        $progressBar->setProgressCharacter("\xF0\x9F\x8C\xAD");
447
448
        return $progressBar;
449
    }
450
451
    private function createProgressCallback(ProgressBar $progressBar = null, &$numAssertions, &$numFailures)
452
    {
453
        return function($type, $buffer) use ($progressBar, &$numAssertions, &$numFailures) {
454
            $numAssertions += $this->extractNumAssertions($buffer);
455
            $numFailures += $this->extractNumFailures($buffer);
456
457
            if ($progressBar) {
458
                if (in_array($buffer, ['F', 'E'])) {
459
                    $progressBar->setBarCharacter('<fg=red>=</>');
460
                }
461
462
                if (in_array($buffer, ['F', 'E', 'S', '.'])) {
463
                    $progressBar->advance();
464
                }
465
            }
466
        };
467
    }
468
}
469