PK ^AVj̛P! ! LICENSEnu W+A Copyright (c) 2013 Brian Scaturro
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.PK ^AVPj j bin/phpunit-sqlite-wrappernu W+A query('SELECT id, command FROM tests WHERE reserved_by_process_id IS NULL ORDER BY file_name LIMIT 1')->fetch()) {
$statement = $db->prepare('UPDATE tests SET reserved_by_process_id = :procId WHERE id = :id AND reserved_by_process_id IS NULL');
$statement->execute([
':procId' => getmypid(),
':id' => $test['id'],
]);
if ($statement->rowCount() !== 1) {
// Seems like this test has already been reserved. Continue to the next one.
continue;
}
try {
if (!preg_match_all('/\'([^\']*)\'[ ]?/', $test['command'], $arguments)) {
throw new \Exception("Failed to parse arguments from command line: \"" . $test['command'] . "\"");
}
$_SERVER['argv'] = $arguments[1];
PHPUnit\TextUI\Command::main(false);
} finally {
$db->prepare('UPDATE tests SET completed = 1 WHERE id = :id')
->execute([':id' => $test['id']]);
}
}
PK ^AV;R9\ bin/phpunit-wrappernu W+A run();
PK ^AVQ& .php_cs.distnu W+A setRiskyAllowed(true)
->setRules([
'@Symfony' => true,
'@Symfony:risky' => true,
'@PHP70Migration' => true,
'array_syntax' => ['syntax' => 'short'],
'combine_consecutive_unsets' => true,
'concat_space' => ['spacing' => 'one'],
'declare_strict_types' => true,
'heredoc_to_nowdoc' => true,
'list_syntax' => ['syntax' => 'long'],
'method_argument_space' => true,
'no_extra_consecutive_blank_lines' => ['break', 'continue', 'extra', 'return', 'throw', 'use', 'parenthesis_brace_block', 'square_brace_block', 'curly_brace_block'],
'no_php4_constructor' => true,
'no_short_echo_tag' => true,
'no_unreachable_default_argument_value' => true,
'no_useless_else' => true,
'no_useless_return' => true,
'ordered_class_elements' => array(
'use_trait',
'constant',
'property',
'construct',
'destruct',
'magic',
'phpunit',
'method',
),
'ordered_imports' => true,
'phpdoc_add_missing_param_annotation' => true,
'phpdoc_order' => true,
'semicolon_after_instruction' => true,
'simplified_null_return' => true,
'strict_comparison' => true,
'strict_param' => true,
'yoda_style' => false,
])
->setFinder(
PhpCsFixer\Finder::create()
->exclude('test/fixtures')
->in(__DIR__)
)
;
PK ^AVt{
composer.jsonnu W+A {
"name": "brianium/paratest",
"require": {
"php": ">=7.1",
"ext-pcre": "*",
"ext-reflection": "*",
"ext-simplexml": "*",
"brianium/habitat": "1.0.0",
"composer/semver": "~1.2",
"phpunit/php-timer": "^2.0",
"phpunit/phpunit": "^7.0",
"symfony/console": "^3.4|^4.0",
"symfony/process": "^3.4|^4.0",
"phpunit/php-code-coverage": "^6.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.3.2"
},
"type": "library",
"description": "Parallel testing for PHP",
"keywords": ["testing","PHPUnit", "concurrent", "parallel"],
"homepage": "https://github.com/paratestphp/paratest",
"license": "MIT",
"authors": [
{
"name": "Brian Scaturro",
"email": "scaturrob@gmail.com",
"homepage": "http://brianscaturro.com",
"role": "Lead"
}
],
"bin": ["bin/paratest"],
"autoload": {
"psr-4": {
"ParaTest\\": ["src/"]
}
},
"autoload-dev": {
"psr-0": {
"": "test/functional"
},
"psr-4": {
"ParaTest\\": "test/unit/"
}
}
}
PK ^AV[c$ $ README.mdnu W+A ParaTest
========
[![Build Status](https://travis-ci.org/paratestphp/paratest.svg?branch=master)](https://travis-ci.org/paratestphp/paratest)
[![Code Coverage](https://scrutinizer-ci.com/g/brianium/paratest/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/brianium/paratest/?branch=master)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/brianium/paratest/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/brianium/paratest/?branch=master)
[![Packagist](https://img.shields.io/packagist/dt/brianium/paratest.svg)](https://packagist.org/packages/brianium/paratest)
The objective of ParaTest is to support parallel testing in PHPUnit. Provided you have well-written PHPUnit tests, you can drop `paratest` in your project and
start using it with no additional bootstrap or configurations!
Benefits
------------
Why use `paratest` over the alternative parallel test runners out there?
* Code Coverage report combining. *Run your tests in N parallel processes and all the code coverage output will be combined into one report.*
* Zero configuration. *After composer install, run with `vendor/bin/paratest -p4 path/to/tests`. That's it!*
* Flexible. *Isolate test files in separate processes or take advantage of WrapperRunner for even faster runs.*
Installation
------------
### Composer ###
To install with composer run the following command:
composer require --dev brianium/paratest
Usage
-----
After installation, the binary can be found at `vendor/bin/paratest`. Usage is as follows:
```
Usage:
paratest [-p|--processes="..."] [-f|--functional] [--no-test-tokens] [-h|--help] [--coverage-clover="..."] [--coverage-html="..."] [--coverage-php="..."] [-m|--max-batch-size="..."] [--filter="..."] [--phpunit="..."] [--runner="..."] [--bootstrap="..."] [-c|--configuration="..."] [-g|--group="..."] [--exclude-group="..."] [--stop-on-failure] [--log-junit="..."] [--colors] [--testsuite[="..."]] [--path="..."] [path]
Arguments:
path The path to a directory or file containing tests. (default: current directory)
Options:
--processes (-p) The number of test processes to run. (default: 5)
--functional (-f) Run methods instead of suites in separate processes.
--no-test-tokens Disable TEST_TOKEN environment variables. (default: variable is set)
--help (-h) Display this help message.
--coverage-clover Generate code coverage report in Clover XML format.
--coverage-html Generate code coverage report in HTML format.
--coverage-php Serialize PHP_CodeCoverage object to file.
--max-batch-size (-m) Max batch size (only for functional mode). (default: 0)
--filter Filter (only for functional mode).
--phpunit The PHPUnit binary to execute. (default: vendor/bin/phpunit)
--runner Runner, WrapperRunner or SqliteRunner. (default: Runner)
--bootstrap The bootstrap file to be used by PHPUnit.
--configuration (-c) The PHPUnit configuration file to use.
--group (-g) Only runs tests from the specified group(s).
--exclude-group Don't run tests from the specified group(s).
--stop-on-failure Don't start any more processes after a failure.
--log-junit Log test execution in JUnit XML format to file.
--colors Displays a colored bar as a test result.
--testsuite Filter which testsuite to run
--path An alias for the path argument.
```
### Optimizing Speed ###
To get the most out of paratest, you have to adjust the parameters carefully.
1. **Adjust the number of processes with `-p`**
To allow full usage of your cpu cores, you should have at least one process per core. More processes allow better resource usage but keep in mind that each process has it's own costs for spawning.
2. **Choose between per-testcase- and per-testmethod-parallelization with `-f`**
Given you have few testcases (classes) with many long running methods, you should use the `-f` option to enable the `functional mode` and allow different methods of the same class to be executed in parallel. Keep in mind that the default is per-testcase-parallelization to address inter-testmethod dependencies. Note that in most projects, using `-f` is **slower** since each test **method** will need to be bootstrapped separately.
3. **Use the WrapperRunner or SqliteRunner if possible**
The default Runner for PHPUnit spawns a new process for each testcase (or method in functional mode). This provides the highest compatibility but comes with the cost of many spawned processes and a bootstrapping for each process. Especially when you have a slow bootstrapping in your tests (like a database setup) you should try the WrapperRunner with `--runner WrapperRunner` or the SqliteRunner with `--runner SqliteRunner`. It spawns one "worker"-process for each parallel process (`-p`), executes the bootstrapping once and reuses these processes for each test executed. That way the overhead of process spawning and bootstrapping is reduced to the minimum.
4. **Tune batch max size `--max-batch-size`**
Batch size will affect on max amount of atomic tests which will be used for single test method.
One atomic test will be either one test method from test class if no data provider available for
method or will be only one item from dataset for method.
Increase this value to reduce per-process overhead and in most cases it will also reduce parallel efficiency.
Decrease this value to increase per-process overhead and in most cases it will also increase parallel efficiency.
If amount of all tests less then max batch size then everything will be processed in one
process thread so paratest is completely useless in that case.
The best way to find the most effective batch size is to test with different batch size values
and select best.
Max batch size = 0 means that grouping in batches will not be used and one batch will equal to
all method tests (one or all from data provider).
Max batch size = 1 means that each batch will contain only one test from data provider or one
method if data provider is not used.
Bigger max batch size can significantly increase phpunit command line length so process can failed.
Decrease max batch size to reduce command line length.
Windows has limit around 32k, Linux - 2048k, Mac OS X - 256k.
### Examples ###
Examples assume your tests are located under `./test/unit`.
```
# Run all unit tests in 8 parallel processes
vendor/bin/paratest -p8 test/unit
```
```
# Run all unit tests in 4 parallel processes with WrapperRunner and output html code coverage report to /tmp/coverage
# (Code coverage requires Xdebug to be installed)
vendor/bin/paratest -p8 --runner=WrapperRunner --coverage-html=/tmp/coverage test/unit
```
### Windows ###
Windows users be sure to use the appropriate batch files.
An example being:
`vendor\bin\paratest.bat --phpunit vendor\bin\phpunit.bat ...`
ParaTest assumes [PSR-0](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-0.md) for loading tests.
For convenience paratest windows version use 79 columns mode to prevent blank lines in standard
80x25 windows console.
PHPUnit Xml Config Support
--------------------------
When running PHPUnit tests, ParaTest will automatically pass the phpunit.xml or phpunit.xml.dist to the phpunit runner
via the --configuration switch. ParaTest also allows the configuration path to be specified manually.
ParaTest will rely on the `testsuites` node of phpunit's xml configuration to handle loading of suites.
The following phpunit config file is used for ParaTest's test cases.
```xml
./tests/
```
Test token
----------
The `TEST_TOKEN` environment variable is guaranteed to have a value that is different
from every other currently running test. This is useful to e.g. use a different database
for each test:
```php
if (getenv('TEST_TOKEN') !== false) { // Using paratest
$dbname = 'testdb_' . getenv('TEST_TOKEN');
} else {
$dbname = 'testdb';
}
```
For Contributors: Testing paratest itself
-------------
ParaTest's test suite depends on PHPUnit being installed via composer. Make sure you run `composer install` after cloning.
**Note that The `display_errors` php.ini directive must be set to `stderr` to run
the test suite.**
To run unit tests:
`vendor/bin/phpunit test/unit`
To run functional tests:
`vendor/bin/phpunit test/functional`
You can run all tests at once by running phpunit from the project directory.
`vendor/bin/phpunit`
ParaTest can run its own test suite by running it from the `bin` directory.
`bin/paratest`
Before creating a Pull Request be sure to run `vendor/bin/php-cs-fixer fix` and
commit the eventual changes.
For an example of ParaTest out in the wild check out the [example](https://github.com/brianium/paratest-selenium).
PK ^AV8 src/Console/Testers/PHPUnit.phpnu W+A addOption('phpunit', null, InputOption::VALUE_REQUIRED, 'The PHPUnit binary to execute. (default: vendor/bin/phpunit)')
->addOption('runner', null, InputOption::VALUE_REQUIRED, 'Runner, WrapperRunner or SqliteRunner. (default: Runner)')
->addOption('bootstrap', null, InputOption::VALUE_REQUIRED, 'The bootstrap file to be used by PHPUnit.')
->addOption('configuration', 'c', InputOption::VALUE_REQUIRED, 'The PHPUnit configuration file to use.')
->addOption('group', 'g', InputOption::VALUE_REQUIRED, 'Only runs tests from the specified group(s).')
->addOption('exclude-group', null, InputOption::VALUE_REQUIRED, 'Don\'t run tests from the specified group(s).')
->addOption('stop-on-failure', null, InputOption::VALUE_NONE, 'Don\'t start any more processes after a failure.')
->addOption('log-junit', null, InputOption::VALUE_REQUIRED, 'Log test execution in JUnit XML format to file.')
->addOption('colors', null, InputOption::VALUE_NONE, 'Displays a colored bar as a test result.')
->addOption('testsuite', null, InputOption::VALUE_OPTIONAL, 'Filter which testsuite to run')
->addArgument('path', InputArgument::OPTIONAL, 'The path to a directory or file containing tests. (default: current directory)')
->addOption('path', null, InputOption::VALUE_REQUIRED, 'An alias for the path argument.');
$this->command = $command;
}
/**
* Executes the PHPUnit Runner. Will Display help if no config and no path
* supplied.
*
* @param InputInterface $input
* @param OutputInterface $output
*
* @return int|mixed
*/
public function execute(InputInterface $input, OutputInterface $output)
{
if (!$this->hasConfig($input) && !$this->hasPath($input)) {
$this->displayHelp($input, $output);
}
$runner = $this->initializeRunner($input);
$runner->run();
return $runner->getExitCode();
}
/**
* Returns whether or not a test path has been supplied
* via option or regular input.
*
* @param InputInterface $input
*
* @return bool
*/
protected function hasPath(InputInterface $input)
{
$argument = $input->getArgument('path');
$option = $input->getOption('path');
return $argument || $option;
}
/**
* Is there a PHPUnit xml configuration present.
*
* @param InputInterface $input
*
* @return bool
*/
protected function hasConfig(InputInterface $input): bool
{
return false !== $this->getConfig($input);
}
/**
* @param \Symfony\Component\Console\Input\InputInterface $input
*
* @return \ParaTest\Runners\PHPUnit\Configuration|bool
*/
protected function getConfig(InputInterface $input)
{
$cwd = getcwd() . DIRECTORY_SEPARATOR;
if ($input->getOption('configuration')) {
$configFilename = $input->getOption('configuration');
} elseif (file_exists($cwd . 'phpunit.xml.dist')) {
$configFilename = $cwd . 'phpunit.xml.dist';
} elseif (file_exists($cwd . 'phpunit.xml')) {
$configFilename = $cwd . 'phpunit.xml';
} else {
return false;
}
return new Configuration($configFilename);
}
/**
* @param \Symfony\Component\Console\Input\InputInterface $input
*
* @throws \RuntimeException
*
* @return array
*/
public function getRunnerOptions(InputInterface $input): array
{
$path = $input->getArgument('path');
$options = $this->getOptions($input);
$bootstrap = $this->getBootstrapFile($input, $options);
$this->requireBootstrap($bootstrap);
if ($this->hasCoverage($options)) {
$options['coverage-php'] = tempnam(sys_get_temp_dir(), 'paratest_');
}
if ($path) {
$options = array_merge(['path' => $path], $options);
}
return $options;
}
/**
* Require the bootstrap. If the file is specified, but does not exist
* then an exception will be raised.
*
* @param $file
*
* @throws \RuntimeException
*/
public function requireBootstrap(string $file)
{
if (!$file) {
return;
}
if (!file_exists($file)) {
$message = sprintf('Bootstrap specified but could not be found (%s)', $file);
throw new \RuntimeException($message);
}
$this->scopedRequire($file);
}
/**
* This function limits the scope of a required file
* so that variables defined in it do not break
* this object's configuration.
*
* @param mixed $file
*/
protected function scopedRequire(string $file)
{
$cwd = getcwd();
require_once $file;
chdir($cwd);
}
/**
* Return whether or not code coverage information should be collected.
*
* @param $options
*
* @return bool
*/
protected function hasCoverage(array $options): bool
{
$isFileFormat = isset($options['coverage-html']) || isset($options['coverage-clover']);
$isTextFormat = isset($options['coverage-text']);
$isPHP = isset($options['coverage-php']);
return $isTextFormat || $isFileFormat && !$isPHP;
}
/**
* Fetch the path to the bootstrap file.
*
* @param InputInterface $input
* @param array $options
*
* @return string
*/
protected function getBootstrapFile(InputInterface $input, array $options): string
{
if (isset($options['bootstrap'])) {
return $options['bootstrap'];
}
if (!$this->hasConfig($input)) {
return '';
}
$config = $this->getConfig($input);
$bootstrap = $config->getBootstrap();
return ($bootstrap) ? $config->getConfigDir() . $bootstrap : '';
}
private function initializeRunner(InputInterface $input): BaseRunner
{
if ($input->getOption('runner')) {
$runnerClass = $input->getOption('runner') ?: '';
$runnerClass = class_exists($runnerClass) ? $runnerClass : ('\\ParaTest\\Runners\\PHPUnit\\' . $runnerClass);
} else {
$runnerClass = Runner::class;
}
if (!class_exists($runnerClass)) {
throw new InvalidArgumentException('Selected runner does not exist.');
}
return new $runnerClass($this->getRunnerOptions($input));
}
}
PK ^AVXE src/Console/Testers/Tester.phpnu W+A getOptions();
foreach ($options as $key => $value) {
if (empty($options[$key])) {
unset($options[$key]);
}
}
return $options;
}
/**
* Displays help for the ParaTestCommand.
*
* @param InputInterface $input
* @param OutputInterface $output
*/
protected function displayHelp(InputInterface $input, OutputInterface $output)
{
$help = $this->command->getApplication()->find('help');
$input = new ArrayInput(['command_name' => 'paratest']);
$help->run($input, $output);
exit(0);
}
}
PK ^AVI25 5 # src/Console/ParaTestApplication.phpnu W+A add(new ParaTestCommand(new PHPUnit()));
return parent::doRun($input, $output);
}
/**
* The default InputDefinition for the application. Leave it to specific
* Tester objects for specifying further definitions.
*
* @return InputDefinition
*/
public function getDefinition(): InputDefinition
{
return new InputDefinition([
new InputOption('--help', '-h', InputOption::VALUE_NONE, 'Display this help message.'),
]);
}
/**
* @param InputInterface $input
*
* @return string
*/
public function getCommandName(InputInterface $input): string
{
return 'paratest';
}
}
PK ^AV
( src/Console/Commands/ParaTestCommand.phpnu W+A tester = $tester;
$this->tester->configure($this);
}
/**
* @return bool
*/
public static function isWhitelistSupported(): bool
{
return Comparator::greaterThanOrEqualTo(\PHPUnit\Runner\Version::id(), '5.0.0');
}
/**
* Ubiquitous configuration options for ParaTest.
*/
protected function configure()
{
$this
->addOption('processes', 'p', InputOption::VALUE_REQUIRED, 'The number of test processes to run.', 5)
->addOption('functional', 'f', InputOption::VALUE_NONE, 'Run methods instead of suites in separate processes.')
->addOption('no-test-tokens', null, InputOption::VALUE_NONE, 'Disable TEST_TOKEN environment variables. (default: variable is set)')
->addOption('help', 'h', InputOption::VALUE_NONE, 'Display this help message.')
->addOption('coverage-clover', null, InputOption::VALUE_REQUIRED, 'Generate code coverage report in Clover XML format.')
->addOption('coverage-html', null, InputOption::VALUE_REQUIRED, 'Generate code coverage report in HTML format.')
->addOption('coverage-php', null, InputOption::VALUE_REQUIRED, 'Serialize PHP_CodeCoverage object to file.')
->addOption('coverage-text', null, InputOption::VALUE_NONE, 'Generate code coverage report in text format.')
->addOption('max-batch-size', 'm', InputOption::VALUE_REQUIRED, 'Max batch size (only for functional mode).', 0)
->addOption('filter', null, InputOption::VALUE_REQUIRED, 'Filter (only for functional mode).');
if (self::isWhitelistSupported()) {
$this->addOption('whitelist', null, InputOption::VALUE_REQUIRED, 'Directory to add to the coverage whitelist.');
}
}
/**
* Executes the specified tester.
*
* @param InputInterface $input
* @param OutputInterface $output
*
* @return int|mixed|null
*/
public function execute(InputInterface $input, OutputInterface $output)
{
return $this->tester->execute($input, $output);
}
}
PK ^AVb src/Console/VersionProvider.phpnu W+A default = $default;
}
public static function getVersion($default = null)
{
$provider = new self($default);
return $provider->getParaTestVersion();
}
public function getParaTestVersion()
{
return $this->getComposerInstalledVersion(self::PACKAGE)
?? $this->getGitVersion()
?? $this->default;
}
public function getGitVersion()
{
$version = null;
$process = new Process('git describe --tags --always --first-parent', __DIR__);
if ($process->run() !== 0) {
return;
}
$version = trim($process->getOutput());
return $version;
}
public function getComposerInstalledVersion($package)
{
if (null === $path = $this->getComposerInstalledJsonPath()) {
return;
}
$result = file_get_contents($path);
if (false === $result) {
return;
}
$struct = json_decode($result, true, 16);
if (!is_array($struct)) {
return;
}
foreach ($struct as $entry) {
if (!is_array($entry)) {
continue;
}
$name = $entry['name'] ?? null;
if (null === $name || $name !== $package) {
continue;
}
$version = $entry['version'] ?? null;
if (null === $version) {
continue;
}
return $version;
}
}
/**
* @return string|null path to composer/installed.json
*/
private function getComposerInstalledJsonPath()
{
$paths = [
// path in the installed version
__DIR__ . '/../../../../composer/installed.json',
// path in the source version
__DIR__ . '/../../vendor/composer/installed.json',
];
// first hit makes it
foreach ($paths as $path) {
if (file_exists($path) && is_readable($path)) {
return $path;
}
}
}
}
PK ^AVM src/Logging/MetaProvider.phpnu W+A getNumericValue($property);
}
if (preg_match(self::$messageMethod, $method, $matches) && $type = strtolower($matches[1])) {
return $this->getMessages($type);
}
}
/**
* Return a value as a float or integer.
*
* @param $property
*
* @return float|int
*/
protected function getNumericValue(string $property)
{
return ($property === 'time')
? (float) ($this->suites[0]->$property)
: (int) ($this->suites[0]->$property);
}
/**
* Return messages for a given type.
*
* @param $type
*
* @return array
*/
protected function getMessages(string $type): array
{
$messages = [];
$suites = $this->isSingle ? $this->suites : $this->suites[0]->suites;
foreach ($suites as $suite) {
$messages = array_merge($messages, array_reduce($suite->cases, function ($result, $case) use ($type) {
return array_merge($result, array_reduce($case->$type, function ($msgs, $msg) {
$msgs[] = $msg['text'];
return $msgs;
}, []));
}, []));
}
return $messages;
}
}
PK ^AV!JT T src/Logging/LogInterpreter.phpnu W+A readers);
}
/**
* Add a new Reader to be included
* in the final results.
*
* @param Reader $reader
*
* @return $this
*/
public function addReader(Reader $reader): self
{
$this->readers[] = $reader;
return $this;
}
/**
* Return all Reader objects associated
* with the LogInterpreter.
*
* @return Reader[]
*/
public function getReaders(): array
{
return $this->readers;
}
/**
* Returns true if total errors and failures
* equals 0, false otherwise
* TODO: Remove this comment if we don't care about skipped tests in callers.
*
* @return bool
*/
public function isSuccessful(): bool
{
$failures = $this->getTotalFailures();
$errors = $this->getTotalErrors();
return $failures === 0 && $errors === 0;
}
/**
* Get all test case objects found within
* the collection of Reader objects.
*
* @return array
*/
public function getCases(): array
{
$cases = [];
foreach ($this->readers as $reader) {
foreach ($reader->getSuites() as $suite) {
$cases = array_merge($cases, $suite->cases);
foreach ($suite->suites as $nested) {
$this->extendEmptyCasesFromSuites($nested->cases, $suite);
$cases = array_merge($cases, $nested->cases);
}
}
}
return $cases;
}
/**
* Fix problem with empty testcase from DataProvider.
*
* @param array $cases
* @param TestSuite $suite
*/
protected function extendEmptyCasesFromSuites(array $cases, TestSuite $suite)
{
$class = $suite->name;
$file = $suite->file;
/** @var TestCase $case */
foreach ($cases as $case) {
if (empty($case->class)) {
$case->class = $class;
}
if (empty($case->file)) {
$case->file = $file;
}
}
}
/**
* Flattens all cases into their respective suites.
*
* @return array $suites a collection of suites and their cases
*/
public function flattenCases(): array
{
$dict = [];
foreach ($this->getCases() as $case) {
if (!isset($dict[$case->file])) {
$dict[$case->file] = new TestSuite($case->class, 0, 0, 0, 0, 0, 0);
}
$dict[$case->file]->cases[] = $case;
$dict[$case->file]->tests += 1;
$dict[$case->file]->assertions += $case->assertions;
$dict[$case->file]->failures += count($case->failures);
$dict[$case->file]->errors += count($case->errors);
$dict[$case->file]->skipped += count($case->skipped);
$dict[$case->file]->time += $case->time;
$dict[$case->file]->file = $case->file;
}
return array_values($dict);
}
/**
* Returns a value as either a float or int.
*
* @param $property
*
* @return float|int
*/
protected function getNumericValue(string $property)
{
return ($property === 'time')
? (float) ($this->accumulate('getTotalTime'))
: (int) ($this->accumulate('getTotal' . ucfirst($property)));
}
/**
* Gets messages of a given type and
* merges them into a single collection.
*
* @param $type
*
* @return array
*/
protected function getMessages(string $type): array
{
return $this->mergeMessages('get' . ucfirst($type));
}
/**
* Flatten messages into a single collection
* based on an accessor method.
*
* @param $method
*
* @return array
*/
private function mergeMessages(string $method): array
{
$messages = [];
foreach ($this->readers as $reader) {
$messages = array_merge($messages, $reader->{$method}());
}
return $messages;
}
/**
* Reduces a collection of readers down to a single
* result based on an accessor.
*
* @param $method
*
* @return mixed
*/
private function accumulate(string $method)
{
return array_reduce($this->readers, function ($result, $reader) use ($method) {
$result += $reader->$method();
return $result;
}, 0);
}
}
PK ^AV*a
a
src/Logging/JUnit/TestSuite.phpnu W+A name = $name;
$this->tests = $tests;
$this->assertions = $assertions;
$this->failures = $failures;
$this->skipped = $skipped;
$this->errors = $errors;
$this->time = $time;
$this->file = $file;
}
/**
* Create a TestSuite from an associative
* array.
*
* @param array $arr
*
* @return TestSuite
*/
public static function suiteFromArray(array $arr): self
{
return new self(
$arr['name'],
$arr['tests'],
$arr['assertions'],
$arr['failures'],
$arr['errors'],
$arr['skipped'],
$arr['time'],
$arr['file']
);
}
/**
* Create a TestSuite from a SimpleXMLElement.
*
* @param \SimpleXMLElement $node
*
* @return TestSuite
*/
public static function suiteFromNode(\SimpleXMLElement $node): self
{
return new self(
(string) $node['name'],
(int) $node['tests'],
(int) $node['assertions'],
(int) $node['failures'],
(int) $node['errors'],
(int) $node['skipped'],
(float) $node['time'],
(string) $node['file']
);
}
}
PK ^AV1с8 src/Logging/JUnit/Reader.phpnu W+A '',
'file' => '',
'tests' => 0,
'assertions' => 0,
'failures' => 0,
'errors' => 0,
'skipped' => 0,
'time' => 0,
];
public function __construct(string $logFile)
{
if (!file_exists($logFile)) {
throw new \InvalidArgumentException("Log file $logFile does not exist");
}
$this->logFile = $logFile;
if (filesize($logFile) === 0) {
throw new \InvalidArgumentException("Log file $logFile is empty. This means a PHPUnit process has crashed.");
}
$logFileContents = file_get_contents($this->logFile);
$this->xml = new \SimpleXMLElement($logFileContents);
$this->init();
}
/**
* Returns whether or not this reader contains only
* a single suite.
*
* @return bool
*/
public function isSingleSuite(): bool
{
return $this->isSingle;
}
/**
* Return the Reader's collection
* of test suites.
*
* @return array
*/
public function getSuites(): array
{
return $this->suites;
}
/**
* Return an array that contains
* each suite's instant feedback. Since
* logs do not contain skipped or incomplete
* tests this array will contain any number of the following
* characters: .,F,E
* TODO: Update this, skipped was added in phpunit.
*
* @return array
*/
public function getFeedback(): array
{
$feedback = [];
$suites = $this->isSingle ? $this->suites : $this->suites[0]->suites;
foreach ($suites as $suite) {
foreach ($suite->cases as $case) {
if ($case->failures) {
$feedback[] = 'F';
} elseif ($case->errors) {
$feedback[] = 'E';
} elseif ($case->skipped) {
$feedback[] = 'S';
} else {
$feedback[] = '.';
}
}
}
return $feedback;
}
/**
* Remove the JUnit xml file.
*/
public function removeLog()
{
unlink($this->logFile);
}
/**
* Initialize the suite collection
* from the JUnit xml document.
*/
protected function init()
{
$this->initSuite();
$cases = $this->getCaseNodes();
foreach ($cases as $file => $nodeArray) {
$this->initSuiteFromCases($nodeArray);
}
}
/**
* Uses an array of testcase nodes to build a suite.
*
* @param array $nodeArray an array of SimpleXMLElement nodes representing testcase elements
*/
protected function initSuiteFromCases(array $nodeArray)
{
$testCases = [];
$properties = $this->caseNodesToSuiteProperties($nodeArray, $testCases);
if (!$this->isSingle) {
$this->addSuite($properties, $testCases);
} else {
$this->suites[0]->cases = $testCases;
}
}
/**
* Creates and adds a TestSuite based on the given
* suite properties and collection of test cases.
*
* @param $properties
* @param $testCases
*/
protected function addSuite($properties, array $testCases)
{
$suite = TestSuite::suiteFromArray($properties);
$suite->cases = $testCases;
$this->suites[0]->suites[] = $suite;
}
/**
* Fold an array of testcase nodes into a suite array.
*
* @param array $nodeArray an array of testcase nodes
* @param array $testCases an array reference. Individual testcases will be placed here.
*
* @return mixed
*/
protected function caseNodesToSuiteProperties(array $nodeArray, array &$testCases = [])
{
$cb = [TestCase::class, 'caseFromNode'];
return array_reduce($nodeArray, function ($result, $c) use (&$testCases, $cb) {
$testCases[] = call_user_func_array($cb, [$c]);
$result['name'] = (string) $c['class'];
$result['file'] = (string) $c['file'];
$result['tests'] = $result['tests'] + 1;
$result['assertions'] += (int) $c['assertions'];
$result['failures'] += count($c->xpath('failure'));
$result['errors'] += count($c->xpath('error'));
$result['skipped'] += count($c->xpath('skipped'));
$result['time'] += (float) ($c['time']);
return $result;
}, static::$defaultSuite);
}
/**
* Return a collection of testcase nodes
* from the xml document.
*
* @return array
*/
protected function getCaseNodes(): array
{
$caseNodes = $this->xml->xpath('//testcase');
$cases = [];
foreach ($caseNodes as $node) {
$case = $node;
if (!isset($cases[(string) $node['file']])) {
$cases[(string) $node['file']] = [];
}
$cases[(string) $node['file']][] = $node;
}
return $cases;
}
/**
* Determine if this reader is a single suite
* and initialize the suite collection with the first
* suite.
*/
protected function initSuite()
{
$suiteNodes = $this->xml->xpath('/testsuites/testsuite/testsuite');
$this->isSingle = count($suiteNodes) === 0;
$node = current($this->xml->xpath('/testsuites/testsuite'));
if ($node !== false) {
$this->suites[] = TestSuite::suiteFromNode($node);
} else {
$this->suites[] = TestSuite::suiteFromArray(self::$defaultSuite);
}
}
}
PK ^AVTd src/Logging/JUnit/TestCase.phpnu W+A name = $name;
$this->class = $class;
$this->file = $file;
$this->line = $line;
$this->assertions = $assertions;
$this->time = $time;
}
/**
* @param string $type
* @param string $text
*/
public function addFailure(string $type, string $text)
{
$this->addDefect('failures', $type, $text);
}
/**
* @param string $type
* @param string $text
*/
public function addError(string $type, string $text)
{
$this->addDefect('errors', $type, $text);
}
/**
* @param string $type
* @param string $text
*/
public function addSkipped(string $type, string $text)
{
$this->addDefect('skipped', $type, $text);
}
/**
* Add a defect type (error or failure).
*
* @param string $collName the name of the collection to add to
* @param $type
* @param $text
*/
protected function addDefect(string $collName, string $type, string $text)
{
$this->{$collName}[] = [
'type' => $type,
'text' => trim($text),
];
}
/**
* Add systemOut result on test (if has failed or have error).
*
* @param mixed $node
*
* @return mixed
*/
public static function addSystemOut(\SimpleXMLElement $node): \SimpleXMLElement
{
$sys = 'system-out';
if (!empty($node->failure)) {
$node->failure = (string) $node->failure . (string) $node->{$sys};
}
if (!empty($node->error)) {
$node->error = (string) $node->error . (string) $node->{$sys};
}
return $node;
}
/**
* Factory method that creates a TestCase object
* from a SimpleXMLElement.
*
* @param \SimpleXMLElement $node
*
* @return TestCase
*/
public static function caseFromNode(\SimpleXMLElement $node): self
{
$case = new self(
(string) $node['name'],
(string) $node['class'],
(string) $node['file'],
(int) $node['line'],
(int) $node['assertions'],
(string) $node['time']
);
$node = self::addSystemOut($node);
$failures = $node->xpath('failure');
$skipped = $node->xpath('skipped');
$errors = $node->xpath('error');
foreach ($failures as $fail) {
$case->addFailure((string) $fail['type'], (string) $fail);
}
foreach ($errors as $err) {
$case->addError((string) $err['type'], (string) $err);
}
foreach ($skipped as $skip) {
$case->addSkipped((string) $skip['type'], (string) $skip);
}
return $case;
}
}
PK ^AV=3 src/Logging/JUnit/Writer.phpnu W+A 0,
'assertions' => 0,
'failures' => 0,
'skipped' => 0,
'errors' => 0,
'time' => 0,
];
public function __construct(LogInterpreter $interpreter, string $name = '')
{
$this->name = $name;
$this->interpreter = $interpreter;
$this->document = new \DOMDocument('1.0', 'UTF-8');
$this->document->formatOutput = true;
}
/**
* Get the name of the root suite being written.
*
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* Returns the xml structure the writer
* will use.
*
* @return string
*/
public function getXml(): string
{
$suites = $this->interpreter->flattenCases();
$root = $this->getSuiteRoot($suites);
foreach ($suites as $suite) {
$snode = $this->appendSuite($root, $suite);
foreach ($suite->cases as $case) {
$cnode = $this->appendCase($snode, $case);
}
}
return $this->document->saveXML();
}
/**
* Write the xml structure to a file path.
*
* @param $path
*/
public function write(string $path)
{
file_put_contents($path, $this->getXml());
}
/**
* Append a testsuite node to the given
* root element.
*
* @param $root
* @param TestSuite $suite
*
* @return \DOMElement
*/
protected function appendSuite(\DOMElement $root, TestSuite $suite): \DOMElement
{
$suiteNode = $this->document->createElement('testsuite');
$vars = get_object_vars($suite);
foreach ($vars as $name => $value) {
if (preg_match(static::$suiteAttrs, $name)) {
$suiteNode->setAttribute($name, (string) $value);
}
}
$root->appendChild($suiteNode);
return $suiteNode;
}
/**
* Append a testcase node to the given testsuite
* node.
*
* @param $suiteNode
* @param TestCase $case
*
* @return \DOMElement
*/
protected function appendCase(\DOMElement $suiteNode, TestCase $case): \DOMElement
{
$caseNode = $this->document->createElement('testcase');
$vars = get_object_vars($case);
foreach ($vars as $name => $value) {
if (preg_match(static::$caseAttrs, $name)) {
if ($this->isEmptyLineAttribute($name, $value)) {
continue;
}
$caseNode->setAttribute($name, (string) $value);
}
}
$suiteNode->appendChild($caseNode);
$this->appendDefects($caseNode, $case->failures, 'failure');
$this->appendDefects($caseNode, $case->errors, 'error');
return $caseNode;
}
/**
* Append error or failure nodes to the given testcase node.
*
* @param $caseNode
* @param $defects
* @param $type
*/
protected function appendDefects(\DOMElement $caseNode, array $defects, string $type)
{
foreach ($defects as $defect) {
$defectNode = $this->document->createElement($type, htmlspecialchars($defect['text'], ENT_XML1) . "\n");
$defectNode->setAttribute('type', $defect['type']);
$caseNode->appendChild($defectNode);
}
}
/**
* Get the root level testsuite node.
*
* @param $suites
*
* @return \DOMElement
*/
protected function getSuiteRoot(array $suites): \DOMElement
{
$testsuites = $this->document->createElement('testsuites');
$this->document->appendChild($testsuites);
if (count($suites) === 1) {
return $testsuites;
}
$rootSuite = $this->document->createElement('testsuite');
$attrs = $this->getSuiteRootAttributes($suites);
foreach ($attrs as $attr => $value) {
$rootSuite->setAttribute($attr, (string) $value);
}
$testsuites->appendChild($rootSuite);
return $rootSuite;
}
/**
* Get the attributes used on the root testsuite
* node.
*
* @param $suites
*
* @return mixed
*/
protected function getSuiteRootAttributes(array $suites)
{
return array_reduce($suites, function (array $result, TestSuite $suite): array {
$result['tests'] += $suite->tests;
$result['assertions'] += $suite->assertions;
$result['failures'] += $suite->failures;
$result['skipped'] += $suite->skipped;
$result['errors'] += $suite->errors;
$result['time'] += $suite->time;
return $result;
}, array_merge(['name' => $this->name], self::$defaultSuite));
}
/**
* Prevent writing empty "line" XML attributes which could break parsers.
*
* @param string $name
* @param mixed $value
*
* @return bool
*/
private function isEmptyLineAttribute(string $name, $value): bool
{
return $name === 'line' && empty($value);
}
}
PK ^AV>z z * src/Coverage/CoverageReporterInterface.phpnu W+A coverage = $coverage;
}
/**
* Generate clover coverage report.
*
* @param string $target Report filename
*/
public function clover(string $target)
{
$clover = new Clover();
$clover->process($this->coverage, $target);
}
/**
* Generate html coverage report.
*
* @param string $target Report filename
*/
public function html(string $target)
{
$html = new Html\Facade();
$html->process($this->coverage, $target);
}
/**
* Generate php coverage report.
*
* @param string $target Report filename
*/
public function php(string $target)
{
$php = new PHP();
$php->process($this->coverage, $target);
}
/**
* Generate text coverage report.
*/
public function text()
{
$text = new Text();
echo $text->process($this->coverage);
}
}
PK ^AV+u^ src/Coverage/CoverageMerger.phpnu W+A coverage) {
$this->coverage = $coverage;
} else {
$this->coverage->merge($coverage);
}
}
/**
* Returns coverage object from file.
*
* @param \SplFileObject $coverageFile coverage file
*
* @return CodeCoverage
*/
private function getCoverageObject(\SplFileObject $coverageFile): CodeCoverage
{
if ('fread(5)) {
return include $coverageFile->getRealPath();
}
$coverageFile->fseek(0);
// the PHPUnit 3.x and below
return unserialize($coverageFile->fread($coverageFile->getSize()));
}
/**
* Adds the coverage contained in $coverageFile and deletes the file afterwards.
*
* @param string $coverageFile Code coverage file
*
* @throws \RuntimeException When coverage file is empty
*/
public function addCoverageFromFile(string $coverageFile = null)
{
if ($coverageFile === null || !file_exists($coverageFile)) {
return;
}
$file = new \SplFileObject($coverageFile);
if (0 === $file->getSize()) {
$extra = 'This means a PHPUnit process has crashed.';
if (!function_exists('xdebug_get_code_coverage')) {
$extra = 'Xdebug is disabled! Enable for coverage.';
}
throw new \RuntimeException(
"Coverage file {$file->getRealPath()} is empty. " . $extra
);
}
$this->addCoverage($this->getCoverageObject($file));
unlink($file->getRealPath());
}
/**
* Get coverage report generator.
*
* @return CoverageReporterInterface
*/
public function getReporter(): CoverageReporterInterface
{
return new CoverageReporter($this->coverage);
}
}
PK ^AV^5 5 % src/Runners/PHPUnit/ResultPrinter.phpnu W+A results = $results;
$this->timer = new Timer();
}
/**
* Adds an ExecutableTest to the tracked results.
*
* @param ExecutableTest $suite
*
* @return $this
*/
public function addTest(ExecutableTest $suite): self
{
$this->suites[] = $suite;
$increment = $suite->getTestCount();
$this->totalCases = $this->totalCases + $increment;
return $this;
}
/**
* Initializes printing constraints, prints header
* information and starts the test timer.
*
* @param Options $options
*/
public function start(Options $options)
{
$this->numTestsWidth = strlen((string) $this->totalCases);
$this->maxColumn = $this->numberOfColumns
+ (DIRECTORY_SEPARATOR === '\\' ? -1 : 0) // fix windows blank lines
- strlen($this->getProgress());
printf(
"\nRunning phpunit in %d process%s with %s%s\n\n",
$options->processes,
$options->processes > 1 ? 'es' : '',
$options->phpunit,
$options->functional ? '. Functional mode is ON.' : ''
);
if (isset($options->filtered['configuration'])) {
printf("Configuration read from %s\n\n", $options->filtered['configuration']->getPath());
}
$this->timer->start();
$this->colors = $options->colors;
$this->processSkipped = $this->isSkippedIncompleTestCanBeTracked($options);
}
/**
* @param string $string
*/
public function println(string $string = '')
{
$this->column = 0;
echo "$string\n";
}
/**
* Prints all results and removes any log files
* used for aggregating results.
*/
public function flush()
{
$this->printResults();
$this->clearLogs();
}
/**
* Print final results.
*/
public function printResults()
{
echo $this->getHeader();
echo $this->getErrors();
echo $this->getFailures();
echo $this->getWarnings();
echo $this->getFooter();
}
/**
* Prints the individual "quick" feedback for run
* tests, that is the ".EF" items.
*
* @param ExecutableTest $test
*/
public function printFeedback(ExecutableTest $test)
{
try {
$reader = new Reader($test->getTempFile());
} catch (\InvalidArgumentException $e) {
throw new \RuntimeException(sprintf(
"%s\n" .
"The process: %s\n" .
"This means a PHPUnit process was unable to run \"%s\"\n",
$e->getMessage(),
$test->getLastCommand(),
$test->getPath()
));
}
$this->results->addReader($reader);
$this->processReaderFeedback($reader, $test->getTestCount());
$this->printTestWarnings($test);
}
/**
* Returns the header containing resource usage.
*
* @return string
*/
public function getHeader(): string
{
return "\n\n" . $this->timer->resourceUsage() . "\n\n";
}
/**
* Add an array of warning strings. These cause the test run to be shown
* as failed.
*/
public function addWarnings(array $warnings)
{
$this->warnings = array_merge($this->warnings, $warnings);
}
/**
* Returns warning messages as a string.
*/
public function getWarnings(): string
{
return $this->getDefects($this->warnings, 'warning');
}
/**
* Whether the test run is successful and has no warnings.
*
* @return bool
*/
public function isSuccessful(): bool
{
return $this->results->isSuccessful() && count($this->warnings) === 0;
}
/**
* Return the footer information reporting success
* or failure.
*
* @return string
*/
public function getFooter(): string
{
return $this->isSuccessful()
? $this->getSuccessFooter()
: $this->getFailedFooter();
}
/**
* Returns the failure messages.
*
* @return string
*/
public function getFailures(): string
{
$failures = $this->results->getFailures();
return $this->getDefects($failures, 'failure');
}
/**
* Returns error messages.
*
* @return string
*/
public function getErrors(): string
{
$errors = $this->results->getErrors();
return $this->getDefects($errors, 'error');
}
/**
* Returns the total cases being printed.
*
* @return int
*/
public function getTotalCases(): int
{
return $this->totalCases;
}
/**
* Process reader feedback and print it.
*
* @param Reader $reader
* @param int $expectedTestCount
*/
protected function processReaderFeedback(Reader $reader, int $expectedTestCount)
{
$feedbackItems = $reader->getFeedback();
$actualTestCount = count($feedbackItems);
$this->processTestOverhead($actualTestCount, $expectedTestCount);
foreach ($feedbackItems as $item) {
$this->printFeedbackItem($item);
if ($item === 'S') {
++$this->totalSkippedOrIncomplete;
}
}
if ($this->processSkipped) {
$this->printSkippedAndIncomplete($actualTestCount, $expectedTestCount);
}
}
/**
* Prints test warnings.
*
* @param ExecutableTest $test
*/
protected function printTestWarnings(ExecutableTest $test)
{
$warnings = $test->getWarnings();
if ($warnings) {
$this->addWarnings($warnings);
foreach ($warnings as $warning) {
$this->printFeedbackItem('W');
}
}
}
/**
* Is skipped/incomplete amount can be properly processed.
*
* @todo Skipped/Incomplete test tracking available only in functional mode for now
* or in regular mode but without group/exclude-group filters.
*
* @param mixed $options
*
* @return bool
*/
protected function isSkippedIncompleTestCanBeTracked(Options $options): bool
{
return $options->functional
|| (empty($options->groups) && empty($options->excludeGroups));
}
/**
* Process test overhead.
*
* In some situations phpunit can return more tests then we expect and in that case
* this method correct total amount of tests so paratest progress will be auto corrected.
*
* @todo May be we need to throw Exception here instead of silent correction.
*
* @param int $actualTestCount
* @param int $expectedTestCount
*/
protected function processTestOverhead(int $actualTestCount, int $expectedTestCount)
{
$overhead = $actualTestCount - $expectedTestCount;
if ($this->processSkipped) {
if ($overhead > 0) {
$this->totalCases += $overhead;
} else {
$this->totalSkippedOrIncomplete += -$overhead;
}
} else {
$this->totalCases += $overhead;
}
}
/**
* Prints S for skipped and incomplete tests.
*
* If for some reason process return less tests than expected then we threat all remaining
* as skipped or incomplete and print them as skipped (S letter)
*
* @param int $actualTestCount
* @param int $expectedTestCount
*/
protected function printSkippedAndIncomplete(int $actualTestCount, int $expectedTestCount)
{
$overhead = $expectedTestCount - $actualTestCount;
if ($overhead > 0) {
for ($i = 0; $i < $overhead; ++$i) {
$this->printFeedbackItem('S');
}
}
}
/**
* Prints a single "quick" feedback item and increments
* the total number of processed cases and the column
* position.
*
* @param $item
*/
protected function printFeedbackItem(string $item)
{
echo $item;
++$this->column;
++$this->casesProcessed;
if ($this->column === $this->maxColumn) {
echo $this->getProgress();
$this->println();
}
}
/**
* Method that returns a formatted string
* for a collection of errors or failures.
*
* @param array $defects
* @param $type
*
* @return string
*/
protected function getDefects(array $defects, string $type): string
{
$count = count($defects);
if ($count === 0) {
return '';
}
$output = sprintf(
"There %s %d %s%s:\n",
($count === 1) ? 'was' : 'were',
$count,
$type,
($count === 1) ? '' : 's'
);
for ($i = 1; $i <= count($defects); ++$i) {
$output .= sprintf("\n%d) %s\n", $i, $defects[$i - 1]);
}
return $output;
}
/**
* Prints progress for large test collections.
*/
protected function getProgress(): string
{
return sprintf(
' %' . $this->numTestsWidth . 'd / %' . $this->numTestsWidth . 'd (%3s%%)',
$this->casesProcessed,
$this->totalCases,
floor(($this->totalCases ? $this->casesProcessed / $this->totalCases : 0) * 100)
);
}
/**
* Get the footer for a test collection that had tests with
* failures or errors.
*
* @return string
*/
private function getFailedFooter(): string
{
$formatString = "FAILURES!\nTests: %d, Assertions: %d, Failures: %d, Errors: %d.\n";
return "\n" . $this->red(
sprintf(
$formatString,
$this->results->getTotalTests(),
$this->results->getTotalAssertions(),
$this->results->getTotalFailures(),
$this->results->getTotalErrors()
)
);
}
/**
* Get the footer for a test collection containing all successful
* tests.
*
* @return string
*/
private function getSuccessFooter(): string
{
$tests = $this->totalCases;
$asserts = $this->results->getTotalAssertions();
if ($this->totalSkippedOrIncomplete > 0) {
// phpunit 4.5 produce NOT plural version for test(s) and assertion(s) in that case
// also it shows result in standard color scheme
return sprintf(
"OK, but incomplete, skipped, or risky tests!\n"
. "Tests: %d, Assertions: %d, Incomplete: %d.\n",
$tests,
$asserts,
$this->totalSkippedOrIncomplete
);
}
// phpunit 4.5 produce plural version for test(s) and assertion(s) in that case
// also it shows result as black text on green background
return $this->green(sprintf(
"OK (%d test%s, %d assertion%s)\n",
$tests,
($tests === 1) ? '' : 's',
$asserts,
($asserts === 1) ? '' : 's'
));
}
private function green(string $text): string
{
if ($this->colors) {
return "\x1b[30;42m\x1b[2K"
. $text
. "\x1b[0m\x1b[2K";
}
return $text;
}
private function red(string $text): string
{
if ($this->colors) {
return "\x1b[37;41m\x1b[2K"
. $text
. "\x1b[0m\x1b[2K";
}
return $text;
}
/**
* Deletes all the temporary log files for ExecutableTest objects
* being printed.
*/
private function clearLogs()
{
foreach ($this->suites as $suite) {
$suite->deleteFile();
}
}
}
PK ^AV$8H % src/Runners/PHPUnit/WrapperRunner.phpnu W+A startWorkers();
$this->assignAllPendingTests();
$this->sendStopMessages();
$this->waitForAllToFinish();
$this->complete();
}
protected function load(SuiteLoader $loader)
{
if ($this->options->functional) {
throw new \RuntimeException('The `functional` option is not supported yet in the WrapperRunner. Only full classes can be run due to the current PHPUnit commands causing classloading issues.');
}
parent::load($loader);
}
protected function startWorkers()
{
$wrapper = realpath(__DIR__ . '/../../../bin/phpunit-wrapper');
for ($i = 1; $i <= $this->options->processes; ++$i) {
$worker = new WrapperWorker();
if ($this->options->noTestTokens) {
$token = null;
$uniqueToken = null;
} else {
$token = $i;
$uniqueToken = uniqid();
}
$worker->start($wrapper, $token, $uniqueToken);
$this->streams[] = $worker->stdout();
$this->workers[] = $worker;
}
}
private function assignAllPendingTests()
{
$phpunit = $this->options->phpunit;
$phpunitOptions = $this->options->filtered;
// $phpunitOptions['no-globals-backup'] = null; // removed in phpunit 6.0
while (count($this->pending)) {
$this->waitForStreamsToChange($this->streams);
foreach ($this->progressedWorkers() as $worker) {
if ($worker->isFree()) {
$this->flushWorker($worker);
$pending = array_shift($this->pending);
if ($pending) {
$worker->assign($pending, $phpunit, $phpunitOptions);
}
}
}
}
}
private function sendStopMessages()
{
foreach ($this->workers as $worker) {
$worker->stop();
}
}
private function waitForAllToFinish()
{
$toStop = $this->workers;
while (count($toStop) > 0) {
$toCheck = $this->streamsOf($toStop);
$new = $this->waitForStreamsToChange($toCheck);
foreach ($this->progressedWorkers() as $index => $worker) {
if (!$worker->isRunning()) {
$this->flushWorker($worker);
unset($toStop[$index]);
}
}
}
}
// put on WorkersPool
private function waitForStreamsToChange(array $modified)
{
$write = [];
$except = [];
$result = stream_select($modified, $write, $except, 1);
if ($result === false) {
throw new \RuntimeException('stream_select() returned an error while waiting for all workers to finish.');
}
$this->modified = $modified;
return $result;
}
/**
* put on WorkersPool.
*
* @return WrapperWorker[]
*/
private function progressedWorkers(): array
{
$result = [];
foreach ($this->modified as $modifiedStream) {
$found = null;
foreach ($this->streams as $index => $stream) {
if ($modifiedStream === $stream) {
$found = $index;
break;
}
}
$result[$found] = $this->workers[$found];
}
$this->modified = [];
return $result;
}
/**
* Returns the output streams of a subset of workers.
*
* @param array keys are positions in $this->workers
*
* @return array
*/
private function streamsOf(array $workers): array
{
$streams = [];
foreach (array_keys($workers) as $index) {
$streams[$index] = $this->streams[$index];
}
return $streams;
}
protected function complete()
{
$this->setExitCode();
$this->printer->printResults();
$this->interpreter->rewind();
$this->log();
$this->logCoverage();
$readers = $this->interpreter->getReaders();
foreach ($readers as $reader) {
$reader->removeLog();
}
}
private function setExitCode()
{
if ($this->interpreter->getTotalErrors()) {
$this->exitcode = self::PHPUNIT_ERRORS;
} elseif ($this->interpreter->getTotalFailures()) {
$this->exitcode = self::PHPUNIT_FAILURES;
} else {
$this->exitcode = 0;
}
}
private function flushWorker(WrapperWorker $worker)
{
if ($this->hasCoverage()) {
$this->getCoverage()->addCoverageFromFile($worker->getCoverageFileName());
}
$worker->printFeedback($this->printer);
$worker->reset();
}
/*
private function testIsStillRunning($test)
{
if(!$test->isDoneRunning()) return true;
$this->setExitCode($test);
$test->stop();
if (static::PHPUNIT_FATAL_ERROR === $test->getExitCode())
throw new \Exception($test->getStderr(), $test->getExitCode());
return false;
}
*/
}
PK ^AVQ ! src/Runners/PHPUnit/SuitePath.phpnu W+A path = $path;
$this->excludedPaths = $excludedPaths;
$this->suffix = $suffix;
}
/**
* @return string
*/
public function getPath(): string
{
return $this->path;
}
/**
* @return string[]
*/
public function getExcludedPaths(): array
{
return $this->excludedPaths;
}
/**
* @return string
*/
public function getSuffix(): string
{
return $this->suffix;
}
/**
* @return string
*/
public function getPattern(): string
{
return '|' . preg_quote($this->getSuffix()) . '$|';
}
}
PK ^AVl% % # src/Runners/PHPUnit/SuiteLoader.phpnu W+A options = $options;
}
/**
* Returns all parsed suite objects as ExecutableTest
* instances.
*
* @return array
*/
public function getSuites(): array
{
return $this->loadedSuites;
}
/**
* Returns a collection of TestMethod objects
* for all loaded ExecutableTest instances.
*
* @return array
*/
public function getTestMethods(): array
{
$methods = [];
foreach ($this->loadedSuites as $suite) {
$methods = array_merge($methods, $suite->getFunctions());
}
return $methods;
}
/**
* Populates the loaded suite collection. Will load suites
* based off a phpunit xml configuration or a specified path.
*
* @param string $path
*
* @throws \RuntimeException
*/
public function load(string $path = '')
{
if (is_object($this->options) && isset($this->options->filtered['configuration'])) {
$configuration = $this->options->filtered['configuration'];
} else {
$configuration = new Configuration('');
}
if ($path) {
$testFileLoader = new TestFileLoader($this->options);
$this->files = array_merge($this->files, $testFileLoader->loadPath($path));
} elseif (isset($this->options->testsuite) && $this->options->testsuite) {
foreach ($configuration->getSuiteByName($this->options->testsuite) as $suite) {
foreach ($suite as $suitePath) {
$testFileLoader = new TestFileLoader($this->options);
$this->files = array_merge($this->files, $testFileLoader->loadSuitePath($suitePath));
}
}
} elseif ($suites = $configuration->getSuites()) {
foreach ($suites as $suite) {
foreach ($suite as $suitePath) {
$testFileLoader = new TestFileLoader($this->options);
$this->files = array_merge($this->files, $testFileLoader->loadSuitePath($suitePath));
}
}
}
if (!$this->files) {
throw new \RuntimeException('No path or configuration provided (tests must end with Test.php)');
}
$this->files = array_unique($this->files); // remove duplicates
$this->initSuites();
}
/**
* Called after all files are loaded. Parses loaded files into
* ExecutableTest objects - either Suite or TestMethod.
*/
protected function initSuites()
{
foreach ($this->files as $path) {
try {
$parser = new Parser($path);
if ($class = $parser->getClass()) {
$this->loadedSuites[$path] = $this->createSuite($path, $class);
}
} catch (NoClassInFileException $e) {
continue;
}
}
}
protected function executableTests(string $path, ParsedClass $class)
{
$executableTests = [];
$methodBatches = $this->getMethodBatches($class);
foreach ($methodBatches as $methodBatch) {
$executableTest = new TestMethod($path, $methodBatch);
$executableTests[] = $executableTest;
}
return $executableTests;
}
/**
* Get method batches.
*
* Identify method dependencies, and group dependents and dependees on a single methodBatch.
* Use max batch size to fill batches.
*
* @param ParsedClass $class
*
* @return array of MethodBatches. Each MethodBatch has an array of method names
*/
protected function getMethodBatches(ParsedClass $class): array
{
$classMethods = $class->getMethods($this->options ? $this->options->annotations : []);
$maxBatchSize = $this->options && $this->options->functional ? $this->options->maxBatchSize : 0;
$batches = [];
foreach ($classMethods as $method) {
$tests = $this->getMethodTests($class, $method, $maxBatchSize !== 0);
// if filter passed to paratest then method tests can be blank if not match to filter
if (!$tests) {
continue;
}
if (($dependsOn = $this->methodDependency($method)) !== null) {
$this->addDependentTestsToBatchSet($batches, $dependsOn, $tests);
} else {
$this->addTestsToBatchSet($batches, $tests, $maxBatchSize);
}
}
return $batches;
}
protected function addDependentTestsToBatchSet(array &$batches, string $dependsOn, array $tests)
{
foreach ($batches as $key => $batch) {
foreach ($batch as $methodName) {
if ($dependsOn === $methodName) {
$batches[$key] = array_merge($batches[$key], $tests);
continue;
}
}
}
}
protected function addTestsToBatchSet(array &$batches, array $tests, int $maxBatchSize)
{
foreach ($tests as $test) {
$lastIndex = count($batches) - 1;
if ($lastIndex !== -1
&& count($batches[$lastIndex]) < $maxBatchSize
) {
$batches[$lastIndex][] = $test;
} else {
$batches[] = [$test];
}
}
}
/**
* Get method all available tests.
*
* With empty filter this method returns single test if doesnt' have data provider or
* data provider is not used and return all test if has data provider and data provider is used.
*
* @param ParsedClass $class parsed class
* @param ParsedObject $method parsed method
* @param bool $useDataProvider try to use data provider or not
*
* @return string[] array of test names
*/
protected function getMethodTests(ParsedClass $class, ParsedFunction $method, bool $useDataProvider = false): array
{
$result = [];
$groups = $this->methodGroups($method);
$dataProvider = $this->methodDataProvider($method);
if ($useDataProvider && isset($dataProvider)) {
$testFullClassName = '\\' . $class->getName();
$testClass = new $testFullClassName();
$result = [];
$datasetKeys = array_keys($testClass->$dataProvider());
foreach ($datasetKeys as $key) {
$test = sprintf(
'%s with data set %s',
$method->getName(),
is_int($key) ? '#' . $key : '"' . $key . '"'
);
if ($this->testMatchOptions($class->getName(), $test, $groups)) {
$result[] = $test;
}
}
} elseif ($this->testMatchOptions($class->getName(), $method->getName(), $groups)) {
$result = [$method->getName()];
}
return $result;
}
protected function testMatchGroupOptions(array $groups): bool
{
if (empty($groups)) {
return true;
}
if (!empty($this->options->groups)
&& !array_intersect($groups, $this->options->groups)
) {
return false;
}
if (!empty($this->options->excludeGroups)
&& array_intersect($groups, $this->options->excludeGroups)
) {
return false;
}
return true;
}
protected function testMatchFilterOptions(string $className, string $name): bool
{
if (empty($this->options->filter)) {
return true;
}
$re = substr($this->options->filter, 0, 1) === '/'
? $this->options->filter
: '/' . $this->options->filter . '/';
$fullName = $className . '::' . $name;
return 1 === preg_match($re, $fullName);
}
protected function testMatchOptions(string $className, string $name, array $group): bool
{
$result = $this->testMatchGroupOptions($group)
&& $this->testMatchFilterOptions($className, $name);
return $result;
}
protected function methodDataProvider(ParsedFunction $method)
{
if (preg_match("/@\bdataProvider\b \b(.*)\b/", $method->getDocBlock(), $matches)) {
return $matches[1];
}
}
protected function methodDependency(ParsedFunction $method)
{
if (preg_match("/@\bdepends\b \b(.*)\b/", $method->getDocBlock(), $matches)) {
return $matches[1];
}
}
protected function methodGroups(ParsedFunction $method)
{
if (preg_match_all("/@\bgroup\b \b(.*)\b/", $method->getDocBlock(), $matches)) {
return $matches[1];
}
return [];
}
protected function createSuite(string $path, ParsedClass $class): Suite
{
return new Suite(
$path,
$this->executableTests(
$path,
$class
),
$class->getName()
);
}
}
PK ^AVL " src/Runners/PHPUnit/BaseRunner.phpnu W+A options = new Options($opts);
$this->interpreter = new LogInterpreter();
$this->printer = new ResultPrinter($this->interpreter);
}
public function run()
{
$this->initialize();
}
/**
* Ensures a valid configuration was supplied. If not
* causes ParaTest to print the error message and exit immediately
* with an exit code of 1.
*/
protected function verifyConfiguration()
{
if (isset($this->options->filtered['configuration']) && !file_exists($this->options->filtered['configuration']->getPath())) {
$this->printer->println(sprintf('Could not read "%s".', $this->options->filtered['configuration']));
exit(1);
}
}
/**
* Builds the collection of pending ExecutableTest objects
* to run. If functional mode is enabled $this->pending will
* contain a collection of TestMethod objects instead of Suite
* objects.
*/
protected function load(SuiteLoader $loader)
{
$loader->load($this->options->path);
$executables = $this->options->functional ? $loader->getTestMethods() : $loader->getSuites();
$this->pending = array_merge($this->pending, $executables);
foreach ($this->pending as $pending) {
$this->printer->addTest($pending);
}
}
/**
* Returns the highest exit code encountered
* throughout the course of test execution.
*
* @return int
*/
public function getExitCode(): int
{
return $this->exitcode;
}
/**
* Write output to JUnit format if requested.
*/
protected function log()
{
if (!isset($this->options->filtered['log-junit'])) {
return;
}
$output = $this->options->filtered['log-junit'];
$writer = new Writer($this->interpreter, $this->options->path);
$writer->write($output);
}
/**
* Write coverage to file if requested.
*/
protected function logCoverage()
{
if (!$this->hasCoverage()) {
return;
}
$filteredOptions = $this->options->filtered;
$reporter = $this->getCoverage()->getReporter();
if (isset($filteredOptions['coverage-clover'])) {
$reporter->clover($filteredOptions['coverage-clover']);
}
if (isset($filteredOptions['coverage-html'])) {
$reporter->html($filteredOptions['coverage-html']);
}
if (isset($filteredOptions['coverage-text'])) {
$reporter->text();
}
$reporter->php($filteredOptions['coverage-php']);
}
protected function initCoverage()
{
if (!isset($this->options->filtered['coverage-php'])) {
return;
}
$this->coverage = new CoverageMerger();
}
/**
* @return bool
*/
protected function hasCoverage(): bool
{
return $this->getCoverage() !== null;
}
/**
* @return CoverageMerger|null
*/
protected function getCoverage()
{
return $this->coverage;
}
protected function initialize(): void
{
$this->verifyConfiguration();
$this->initCoverage();
$this->load(new SuiteLoader($this->options));
$this->printer->start($this->options);
}
}
PK ^AVf7 7 src/Runners/PHPUnit/Runner.phpnu W+A initTokens();
}
/**
* The money maker. Runs all ExecutableTest objects in separate processes.
*/
public function run()
{
parent::run();
while (count($this->running) || count($this->pending)) {
foreach ($this->running as $key => $test) {
if (!$this->testIsStillRunning($test)) {
unset($this->running[$key]);
$this->releaseToken($key);
}
}
$this->fillRunQueue();
usleep(10000);
}
$this->complete();
}
/**
* Finalizes the run process. This method
* prints all results, rewinds the log interpreter,
* logs any results to JUnit, and cleans up temporary
* files.
*/
private function complete()
{
$this->printer->printResults();
$this->interpreter->rewind();
$this->log();
$this->logCoverage();
$readers = $this->interpreter->getReaders();
foreach ($readers as $reader) {
$reader->removeLog();
}
}
/**
* This method removes ExecutableTest objects from the pending collection
* and adds them to the running collection. It is also in charge of recycling and
* acquiring available test tokens for use.
*/
private function fillRunQueue()
{
$opts = $this->options;
while (count($this->pending) && count($this->running) < $opts->processes) {
$tokenData = $this->getNextAvailableToken();
if ($tokenData !== false) {
$this->acquireToken($tokenData['token']);
$env = ['TEST_TOKEN' => $tokenData['token'], 'UNIQUE_TEST_TOKEN' => $tokenData['unique']] + Habitat::getAll();
$this->running[$tokenData['token']] = array_shift($this->pending)->run($opts->phpunit, $opts->filtered, $env);
}
}
}
/**
* Returns whether or not a test has finished being
* executed. If it has, this method also halts a test process - optionally
* throwing an exception if a fatal error has occurred -
* prints feedback, and updates the overall exit code.
*
* @param ExecutableTest $test
*
* @throws \Exception
*
* @return bool
*/
private function testIsStillRunning(ExecutableTest $test): bool
{
if (!$test->isDoneRunning()) {
return true;
}
$this->setExitCode($test);
$test->stop();
if ($this->options->stopOnFailure && $test->getExitCode() > 0) {
$this->pending = [];
}
if (static::PHPUNIT_FATAL_ERROR === $test->getExitCode()) {
$errorOutput = $test->getStderr();
if (!$errorOutput) {
$errorOutput = $test->getStdout();
}
throw new \Exception($errorOutput);
}
$this->printer->printFeedback($test);
if ($this->hasCoverage()) {
$this->addCoverage($test);
}
return false;
}
/**
* If the provided test object has an exit code
* higher than the currently set exit code, that exit
* code will be set as the overall exit code.
*
* @param ExecutableTest $test
*/
private function setExitCode(ExecutableTest $test)
{
$exit = $test->getExitCode();
if ($exit > $this->exitcode) {
$this->exitcode = $exit;
}
}
/**
* Initialize the available test tokens based
* on how many processes ParaTest will be run in.
*/
protected function initTokens()
{
$this->tokens = [];
for ($i = 1; $i <= $this->options->processes; ++$i) {
$this->tokens[$i] = ['token' => $i, 'unique' => uniqid(sprintf('%s_', $i)), 'available' => true];
}
}
/**
* Gets the next token that is available to be acquired
* from a finished process.
*
* @return bool|array
*/
protected function getNextAvailableToken()
{
foreach ($this->tokens as $data) {
if ($data['available']) {
return $data;
}
}
return false;
}
/**
* Flag a token as available for use.
*
* @param string $tokenIdentifier
*/
protected function releaseToken($tokenIdentifier)
{
$filtered = array_filter($this->tokens, function ($val) use ($tokenIdentifier) {
return $val['token'] === $tokenIdentifier;
});
$keys = array_keys($filtered);
$this->tokens[$keys[0]]['available'] = true;
}
/**
* Flag a token as acquired and not available for use.
*
* @param string $tokenIdentifier
*/
protected function acquireToken($tokenIdentifier)
{
$filtered = array_filter($this->tokens, function ($val) use ($tokenIdentifier) {
return $val['token'] === $tokenIdentifier;
});
$keys = array_keys($filtered);
$this->tokens[$keys[0]]['available'] = false;
}
/**
* @param ExecutableTest $test
*/
private function addCoverage(ExecutableTest $test)
{
$coverageFile = $test->getCoverageFileName();
$this->getCoverage()->addCoverageFromFile($coverageFile);
}
}
PK ^AV~S src/Runners/PHPUnit/Suite.phpnu W+A functions = $functions;
}
/**
* Return the collection of test methods.
*
* @return array
*/
public function getFunctions(): array
{
return $this->functions;
}
/**
* Get the expected count of tests to be executed.
*
* @return int
*/
public function getTestCount(): int
{
return count($this->functions);
}
}
PK ^AV^x % src/Runners/PHPUnit/Configuration.phpnu W+A nodes inside of a
* PHPUnit configuration.
*
* @var array
*/
protected $suites = [];
public function __construct(string $path)
{
$this->path = $path;
if (file_exists($path)) {
$this->xml = simplexml_load_string(file_get_contents($path));
}
}
/**
* Converting the configuration to a string
* returns the configuration path.
*
* @return string
*/
public function __toString(): string
{
return $this->path;
}
/**
* Get the bootstrap PHPUnit configuration attribute.
*
* @return string The bootstrap attribute or empty string if not set
*/
public function getBootstrap(): string
{
if ($this->xml) {
return (string) $this->xml->attributes()->bootstrap;
}
return '';
}
/**
* Returns the path to the phpunit configuration
* file.
*
* @return string
*/
public function getPath(): string
{
return $this->path;
}
/**
* Return the contents of the nodes
* contained in a PHPUnit configuration.
*
* @return SuitePath[][]|null
*/
public function getSuites()
{
if (!$this->xml) {
return;
}
$suites = [];
$nodes = $this->xml->xpath('//testsuites/testsuite');
foreach ($nodes as $node) {
$suites = array_merge_recursive($suites, $this->getSuiteByName((string) $node['name']));
}
return $suites;
}
/**
* Return the contents of the nodes
* contained in a PHPUnit configuration.
*
* @param string $suiteName
*
* @return SuitePath[]|null
*/
public function getSuiteByName(string $suiteName)
{
$nodes = $this->xml->xpath(sprintf('//testsuite[@name="%s"]', $suiteName));
$suites = [];
$excludedPaths = [];
foreach ($nodes as $node) {
foreach ($this->availableNodes as $nodeName) {
foreach ($node->{$nodeName} as $nodeContent) {
switch ($nodeName) {
case 'exclude':
foreach ($this->getSuitePaths((string) $nodeContent) as $excludedPath) {
$excludedPaths[$excludedPath] = $excludedPath;
}
break;
case 'testsuite':
$suites = array_merge_recursive($suites, $this->getSuiteByName((string) $nodeContent));
break;
case 'directory':
// Replicate behaviour of PHPUnit
// if a directory is included and excluded at the same time, then it is considered included
foreach ($this->getSuitePaths((string) $nodeContent) as $dir) {
if (array_key_exists($dir, $excludedPaths)) {
unset($excludedPaths[$dir]);
}
}
// no break on purpose
default:
foreach ($this->getSuitePaths((string) $nodeContent) as $path) {
$suites[(string) $node['name']][] = new SuitePath(
$path,
$excludedPaths,
(string) $nodeContent->attributes()->suffix
);
}
break;
}
}
}
}
return $suites;
}
/**
* Return the path of the directory
* that contains the phpunit configuration.
*
* @return string
*/
public function getConfigDir(): string
{
return dirname($this->path) . DIRECTORY_SEPARATOR;
}
/**
* Returns a suite paths relative to the config file.
*
* @param $path
*
* @return array|string[]
*/
public function getSuitePaths(string $path)
{
$real = realpath($this->getConfigDir() . $path);
if ($real !== false) {
return [$real];
}
if ($this->isGlobRequired($path)) {
$paths = [];
foreach (glob($this->getConfigDir() . $path, GLOB_ONLYDIR) as $path) {
if (($path = realpath($path)) !== false) {
$paths[] = $path;
}
}
return $paths;
}
throw new \RuntimeException("Suite path $path could not be found");
}
/**
* Returns true if path needs globbing (like a /path/*-to/string).
*
* @param string $path
*
* @return bool
*/
public function isGlobRequired(string $path): bool
{
return strpos($path, '*') !== false;
}
}
PK ^AVq44" 4" src/Runners/PHPUnit/Options.phpnu W+A $value) {
$opts[$opt] = $opts[$opt] ?? $value;
}
$this->processes = $opts['processes'];
$this->path = $opts['path'];
$this->phpunit = $opts['phpunit'];
$this->functional = $opts['functional'];
$this->stopOnFailure = $opts['stop-on-failure'];
$this->runner = $opts['runner'];
$this->noTestTokens = $opts['no-test-tokens'];
$this->colors = $opts['colors'];
$this->testsuite = $opts['testsuite'];
$this->maxBatchSize = (int) $opts['max-batch-size'];
$this->filter = $opts['filter'];
// we need to register that options if they are blank but do not get them as
// key with null value in $this->filtered as it will create problems for
// phpunit command line generation (it will add them in command line with no value
// and it's wrong because group and exclude-group options require value when passed
// to phpunit)
$this->groups = isset($opts['group']) && $opts['group'] !== ''
? explode(',', $opts['group'])
: [];
$this->excludeGroups = isset($opts['exclude-group']) && $opts['exclude-group'] !== ''
? explode(',', $opts['exclude-group'])
: [];
if (isset($opts['filter']) && strlen($opts['filter']) > 0 && !$this->functional) {
throw new \RuntimeException('Option --filter is not implemented for non functional mode');
}
$this->filtered = $this->filterOptions($opts);
$this->initAnnotations();
}
/**
* Public read accessibility.
*
* @param string $var
*
* @return mixed
*/
public function __get(string $var)
{
return $this->{$var};
}
/**
* Public read accessibility
* (e.g. to make empty($options->property) work as expected).
*
* @param string $var
*
* @return mixed
*/
public function __isset(string $var): bool
{
return isset($this->{$var});
}
/**
* Returns a collection of ParaTest's default
* option values.
*
* @return array
*/
protected static function defaults(): array
{
return [
'processes' => 5,
'path' => '',
'phpunit' => static::phpunit(),
'functional' => false,
'stop-on-failure' => false,
'runner' => 'Runner',
'no-test-tokens' => false,
'colors' => false,
'testsuite' => '',
'max-batch-size' => 0,
'filter' => null,
];
}
/**
* Get the path to phpunit
* First checks if a Windows batch script is in the composer vendors directory.
* Composer automatically handles creating a .bat file, so if on windows this should be the case.
* Second look for the phpunit binary under nix
* Defaults to phpunit on the users PATH.
*
* @return string $phpunit the path to phpunit
*/
protected static function phpunit(): string
{
$vendor = static::vendorDir();
$phpunit = $vendor . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'phpunit';
$batch = $phpunit . '.bat';
if (DIRECTORY_SEPARATOR === '\\' && file_exists($batch)) {
return $phpunit . '.bat';
} elseif (file_exists($phpunit)) {
return $phpunit;
}
return 'phpunit';
}
/**
* Get the path to the vendor directory
* First assumes vendor directory is accessible from src (i.e development)
* Second assumes vendor directory is accessible within src.
*/
protected static function vendorDir(): string
{
$vendor = dirname(dirname(dirname(__DIR__))) . DIRECTORY_SEPARATOR . 'vendor';
if (!file_exists($vendor)) {
$vendor = dirname(dirname(dirname(dirname(dirname(__DIR__)))));
}
return $vendor;
}
/**
* Filter options to distinguish between paratest
* internal options and any other options.
*/
protected function filterOptions(array $options): array
{
$filtered = array_diff_key($options, [
'processes' => $this->processes,
'path' => $this->path,
'phpunit' => $this->phpunit,
'functional' => $this->functional,
'stop-on-failure' => $this->stopOnFailure,
'runner' => $this->runner,
'no-test-tokens' => $this->noTestTokens,
'colors' => $this->colors,
'testsuite' => $this->testsuite,
'max-batch-size' => $this->maxBatchSize,
'filter' => $this->filter,
]);
if ($configuration = $this->getConfigurationPath($filtered)) {
$filtered['configuration'] = new Configuration($configuration);
}
return $filtered;
}
/**
* Take an array of filtered options and return a
* configuration path.
*
* @param $filtered
*
* @return string|null
*/
protected function getConfigurationPath(array $filtered)
{
if (isset($filtered['configuration'])) {
return $this->getDefaultConfigurationForPath($filtered['configuration'], $filtered['configuration']);
}
return $this->getDefaultConfigurationForPath();
}
/**
* Retrieve the default configuration given a path (directory or file).
* This will search into the directory, if a directory is specified.
*
* @param string $path The path to search into
* @param string $default The default value to give back
*
* @return string|null
*/
private function getDefaultConfigurationForPath(string $path = '.', string $default = null)
{
if ($this->isFile($path)) {
return realpath($path);
}
$path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$suffixes = ['phpunit.xml', 'phpunit.xml.dist'];
foreach ($suffixes as $suffix) {
if ($this->isFile($path . $suffix)) {
return realpath($path . $suffix);
}
}
return $default;
}
/**
* Load options that are represented by annotations
* inside of tests i.e @group group1 = --group group1.
*/
protected function initAnnotations()
{
$annotatedOptions = ['group'];
foreach ($this->filtered as $key => $value) {
if (array_search($key, $annotatedOptions, true) !== false) {
$this->annotations[$key] = $value;
}
}
}
/**
* @param $file
*
* @return bool
*/
private function isFile(string $file): bool
{
return file_exists($file) && !is_dir($file);
}
}
PK ^AVBs $ src/Runners/PHPUnit/SqliteRunner.phpnu W+A dbFileName = (string)($opts['database'] ?? tempnam(sys_get_temp_dir(), 'paratest_db_'));
$this->db = new PDO('sqlite:' . $this->dbFileName);
}
public function run()
{
$this->initialize();
$this->createTable();
$this->assignAllPendingTests();
$this->startWorkers();
$this->waitForAllToFinish();
$this->complete();
$this->checkIfWorkersCrashed();
}
/**
* Start all workers.
*/
protected function startWorkers(): void
{
$wrapper = realpath(__DIR__ . '/../../../bin/phpunit-sqlite-wrapper');
for ($i = 1; $i <= $this->options->processes; ++$i) {
$worker = new SqliteWorker($this->dbFileName);
if ($this->options->noTestTokens) {
$token = null;
$uniqueToken = null;
} else {
$token = $i;
$uniqueToken = uniqid();
}
$worker->start($wrapper, $token, $uniqueToken);
$this->workers[] = $worker;
}
}
/**
* Wait for all workers to complete their tests and print output.
*/
private function waitForAllToFinish(): void
{
do {
foreach ($this->workers as $key => $worker) {
if (!$worker->isRunning()) {
unset($this->workers[$key]);
}
}
usleep(10000);
$this->printOutput();
} while (count($this->workers) > 0);
}
/**
* Initialize test queue table.
*
* @throws Exception
*/
private function createTable(): void
{
$statement = 'CREATE TABLE tests (
id INTEGER PRIMARY KEY,
command TEXT NOT NULL UNIQUE,
file_name TEXT NOT NULL,
reserved_by_process_id INTEGER,
completed INTEGER DEFAULT 0
)';
if ($this->db->exec($statement) === false) {
throw new Exception('Error while creating sqlite database table: ' . $this->db->errorCode());
}
}
/**
* Push all tests onto test queue.
*/
private function assignAllPendingTests(): void
{
foreach ($this->pending as $fileName => $test) {
$this->db->prepare('INSERT INTO tests (command, file_name) VALUES (:command, :fileName)')
->execute([
':command' => $test->command($this->options->phpunit, $this->options->filtered),
':fileName' => $fileName
]);
}
}
/**
* Loop through all completed tests and print their output.
*/
private function printOutput(): void
{
foreach ($this->db->query('SELECT id, file_name FROM tests WHERE completed = 1')->fetchAll() as $test) {
$this->printer->printFeedback($this->pending[$test['file_name']]);
$this->db->prepare('DELETE FROM tests WHERE id = :id')->execute([
'id' => $test['id']
]);
}
}
public function __destruct()
{
if ($this->db !== null) {
unset($this->db);
unlink($this->dbFileName);
}
}
/**
* Make sure that all tests were executed successfully.
*/
private function checkIfWorkersCrashed(): void
{
if ($this->db->query('SELECT COUNT(id) FROM tests')->fetchColumn(0) === "0") {
return;
}
throw new RuntimeException(
'Some workers have crashed.' . PHP_EOL
. '----------------------' . PHP_EOL
. 'All workers have quit, but some tests are still to be executed.' . PHP_EOL
. 'This may be the case if some tests were killed forcefully (for example, using exit()).' . PHP_EOL
. '----------------------' . PHP_EOL
. 'Failed test command(s):' . PHP_EOL
. '----------------------' . PHP_EOL
. implode(PHP_EOL, $this->db->query('SELECT command FROM tests')->fetchAll(PDO::FETCH_COLUMN))
);
}
}PK ^AVfmm " src/Runners/PHPUnit/TestMethod.phpnu W+A path = $testPath;
// for compatibility with other code (tests), which can pass string (one filter)
// instead of array of filters
$this->filters = $filters;
}
/**
* Returns the test method's filters.
*
* @return string[]
*/
public function getFilters(): array
{
return $this->filters;
}
/**
* Returns the test method's name.
*
* This method will join all filters via pipe character and return as string.
*
* @return string
*/
public function getName(): string
{
return implode('|', $this->filters);
}
/**
* Additional processing for options being passed to PHPUnit.
*
* This sets up the --filter switch used to run a single PHPUnit test method.
* This method also provide escaping for method name to be used as filter regexp.
*
* @param array $options
*
* @return array
*/
protected function prepareOptions(array $options): array
{
$re = array_reduce($this->filters, function ($r, $v) {
$isDataSet = strpos($v, ' with data set ') !== false;
return ($r ? $r . '|' : '') . preg_quote($v, '/') . ($isDataSet ? '$' : "(?:\s|\$)");
});
$options['filter'] = '/' . $re . '/';
return $options;
}
/**
* Get the expected count of tests to be executed.
*
* @return int
*/
public function getTestCount(): int
{
return count($this->filters);
}
}
PK ^AV@ &