Initial commit

This commit is contained in:
ftc
2023-05-17 23:24:39 +03:00
commit 696600086f
5571 changed files with 2035224 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
/package
/.github
/tests
/vendor
/*.md
/*.yml
/codecept.bat
/nitpick.json
/ruleset.xml
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2011 Michael Bodnarchuk and contributors
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.
+47
View File
@@ -0,0 +1,47 @@
<?php
require_once __DIR__ . '/autoload.php';
use Codeception\Application;
call_user_func(static function () {
$app = new Application('Codeception', Codeception\Codecept::VERSION);
$app->add(new Codeception\Command\Build('build'));
$app->add(new Codeception\Command\Run('run'));
$app->add(new Codeception\Command\Init('init'));
$app->add(new Codeception\Command\Console('console'));
$app->add(new Codeception\Command\Bootstrap('bootstrap'));
$app->add(new Codeception\Command\GenerateCept('generate:cept'));
$app->add(new Codeception\Command\GenerateCest('generate:cest'));
$app->add(new Codeception\Command\GenerateTest('generate:test'));
$app->add(new Codeception\Command\GenerateSuite('generate:suite'));
$app->add(new Codeception\Command\GenerateHelper('generate:helper'));
$app->add(new Codeception\Command\GenerateScenarios('generate:scenarios'));
$app->add(new Codeception\Command\Clean('clean'));
$app->add(new Codeception\Command\GenerateGroup('generate:groupobject'));
$app->add(new Codeception\Command\GeneratePageObject('generate:pageobject'));
$app->add(new Codeception\Command\GenerateStepObject('generate:stepobject'));
$app->add(new Codeception\Command\GenerateSnapshot('generate:snapshot'));
$app->add(new Codeception\Command\GenerateEnvironment('generate:environment'));
$app->add(new Codeception\Command\GenerateFeature('generate:feature'));
$app->add(new Codeception\Command\GherkinSnippets('gherkin:snippets'));
$app->add(new Codeception\Command\GherkinSteps('gherkin:steps'));
$app->add(new Codeception\Command\DryRun('dry-run'));
$app->add(new Codeception\Command\ConfigValidate('config:validate'));
// Suggests package
if (class_exists('Stecman\Component\Symfony\Console\BashCompletion\CompletionCommand')) {
$app->add(new Codeception\Command\Completion());
} else {
$app->add(new Codeception\Command\CompletionFallback());
}
$app->registerCustomCommands();
// add only if within a phar archive.
if ('phar:' === substr(__FILE__, 0, 5)) {
$app->add(new Codeception\Command\SelfUpdate('self-update'));
}
$app->run();
});
+49
View File
@@ -0,0 +1,49 @@
<?php
$autoloadFile = './vendor/codeception/codeception/autoload.php';
if (( !isset($argv) || (isset($argv) && !in_array('--no-redirect', $argv)) ) && file_exists('./vendor/autoload.php') && file_exists($autoloadFile) && __FILE__ != realpath($autoloadFile)) {
//for global installation or phar file
fwrite(
STDERR,
"\n==== Redirecting to Composer-installed version in vendor/codeception. You can skip this using --no-redirect ====\n"
);
if (file_exists('./vendor/codeception/codeception/app.php')) {
//codeception v4+
require './vendor/codeception/codeception/app.php';
} else {
//older version
require $autoloadFile;
//require package/bin instead of codecept to avoid printing hashbang line
require './vendor/codeception/codeception/package/bin';
}
die;
} elseif (file_exists(__DIR__ . '/vendor/autoload.php')) {
// for phar
require_once __DIR__ . '/vendor/autoload.php';
} elseif (file_exists(__DIR__ . '/../../autoload.php')) {
//for composer
require_once __DIR__ . '/../../autoload.php';
}
unset($autoloadFile);
if (isset($argv)) {
$argv = array_values(array_diff($argv, ['--no-redirect']));
}
if (isset($_SERVER['argv'])) {
$_SERVER['argv'] = array_values(array_diff($_SERVER['argv'], ['--no-redirect']));
}
// @codingStandardsIgnoreStart
include_once __DIR__ . DIRECTORY_SEPARATOR . 'shim.php';
// compat
if (PHP_MAJOR_VERSION < 7) {
if (false === interface_exists('Throwable', false)) {
interface Throwable {};
}
if (false === class_exists('ParseError', false)) {
class ParseError extends \Exception {};
}
}
// @codingStandardsIgnoreEnd
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env php
<?php
/**
* Codeception CLI
*/
require __DIR__ . '/app.php';
+7
View File
@@ -0,0 +1,7 @@
@echo off
if "%PHP_PEAR_PHP_BIN%" neq "" (
set PHPBIN=%PHP_PEAR_PHP_BIN%
) else set PHPBIN=php
"%PHPBIN%" "codecept" %*
+76
View File
@@ -0,0 +1,76 @@
{
"name":"codeception/codeception",
"description":"BDD-style testing framework",
"keywords":["BDD", "acceptance testing", "functional testing", "unit testing", "tdd"],
"homepage":"https://codeception.com/",
"type":"library",
"license":"MIT",
"authors":[
{
"name":"Michael Bodnarchuk",
"email":"davert@mail.ua",
"homepage":"https://codegyre.com"
}
],
"minimum-stability": "RC",
"require": {
"php": ">=5.6.0 <9.0",
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"codeception/lib-asserts": "^1.0 | 2.0.*@dev",
"guzzlehttp/psr7": "^1.4 | ^2.0",
"symfony/finder": ">=2.7 <6.0",
"symfony/console": ">=2.7 <6.0",
"symfony/event-dispatcher": ">=2.7 <6.0",
"symfony/yaml": ">=2.7 <6.0",
"symfony/css-selector": ">=2.7 <6.0",
"behat/gherkin": "^4.4.0",
"codeception/phpunit-wrapper": ">6.0.15 <6.1.0 | ^6.6.1 | ^7.7.1 | ^8.1.1 | ^9.0",
"codeception/stub": "^2.0 | ^3.0 | ^4.0"
},
"require-dev": {
"monolog/monolog": "~1.8",
"codeception/specify": "~0.3",
"squizlabs/php_codesniffer": "~2.0",
"vlucas/phpdotenv": "^2.0 | ^3.0 | ^4.0 | ^5.0",
"symfony/process": ">=2.7 <6.0",
"codeception/module-asserts": "^1.0 | 2.0.*@dev",
"codeception/module-cli": "^1.0 | 2.0.*@dev",
"codeception/module-db": "^1.0 | 2.0.*@dev",
"codeception/module-filesystem": "^1.0 | 2.0.*@dev",
"codeception/module-phpbrowser": "^1.0 | 2.0.*@dev",
"codeception/util-universalframework": "*@dev"
},
"suggest": {
"hoa/console": "For interactive console functionality",
"codeception/specify": "BDD-style code blocks",
"codeception/verify": "BDD-style assertions",
"symfony/phpunit-bridge": "For phpunit-bridge support",
"stecman/symfony-console-completion": "For BASH autocompletion"
},
"autoload":{
"psr-4":{
"Codeception\\": "src/Codeception",
"Codeception\\Extension\\": "ext"
},
"files": ["functions.php"]
},
"autoload-dev": {
"classmap": [
"tests/cli/_steps",
"tests/data/DummyClass.php",
"tests/data/claypit/tests/_data"
]
},
"extra": {
"branch-alias": {
}
},
"bin":["codecept"],
"config": {
"platform": {}
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace Codeception\Extension;
use Codeception\Event\FailEvent;
use Codeception\Events;
use Codeception\Extension;
use Codeception\Subscriber\Console;
/**
* DotReporter provides less verbose output for test execution.
* Like PHPUnit printer it prints dots "." for successful testes and "F" for failures.
*
* ![](https://cloud.githubusercontent.com/assets/220264/26132800/4d23f336-3aab-11e7-81ba-2896a4c623d2.png)
*
* ```bash
* ..........
* ..........
* ..........
* ..........
* ..........
* ..........
* ..........
* ..........
*
* Time: 2.07 seconds, Memory: 20.00MB
*
* OK (80 tests, 124 assertions)
* ```
*
*
* Enable this reporter with `--ext option`
*
* ```
* codecept run --ext DotReporter
* ```
*
* Failures and Errors are printed by a standard Codeception reporter.
* Use this extension as an example for building custom reporters.
*/
class DotReporter extends Extension
{
/**
* @var Console
*/
protected $standardReporter;
protected $errors = [];
protected $failures = [];
protected $width = 10;
protected $currentPos = 0;
public function _initialize()
{
$this->options['silent'] = false; // turn on printing for this extension
$this->_reconfigure(['settings' => ['silent' => true]]); // turn off printing for everything else
$this->standardReporter = new Console($this->options);
$this->width = $this->standardReporter->detectWidth();
}
// we are listening for events
public static $events = [
Events::SUITE_BEFORE => 'beforeSuite',
Events::TEST_SUCCESS => 'success',
Events::TEST_FAIL => 'fail',
Events::TEST_ERROR => 'error',
Events::TEST_SKIPPED => 'skipped',
Events::TEST_FAIL_PRINT => 'printFailed'
];
public function beforeSuite()
{
$this->writeln("");
}
public function success()
{
$this->printChar('.');
}
public function fail(FailEvent $e)
{
$this->printChar("<error>F</error>");
}
public function error(FailEvent $e)
{
$this->printChar('<error>E</error>');
}
public function skipped()
{
$this->printChar('S');
}
protected function printChar($char)
{
if ($this->currentPos >= $this->width) {
$this->writeln('');
$this->currentPos = 0;
}
$this->write($char);
$this->currentPos++;
}
public function printFailed(FailEvent $event)
{
$this->standardReporter->printFail($event);
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace Codeception\Extension;
use Codeception\Event\FailEvent;
use Codeception\Event\StepEvent;
use Codeception\Event\SuiteEvent;
use Codeception\Event\TestEvent;
use Codeception\Events;
use Codeception\Exception\ConfigurationException;
use Codeception\Exception\ExtensionException;
use Codeception\Extension;
use Codeception\Test\Descriptor;
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\RotatingFileHandler;
/**
* Log suites/tests/steps using Monolog library.
* Monolog should be installed additionally by Composer.
*
* ```
* composer require monolog/monolog
* ```
*
* Codeception's core/internal stuff is logged into `tests/_output/codeception.log`.
* Test suites' steps are logged into `tests/_output/<test_full_name>-<rotation_date>.log`.
*
* To enable this module add to your `codeception.yml`:
*
* ``` yaml
* extensions:
* enabled: [Codeception\Extension\Logger]
* ```
*
* #### Config
*
* * `max_files` (default: 3) - how many log files to keep
*
*/
class Logger extends Extension
{
public static $events = [
Events::SUITE_BEFORE => 'beforeSuite',
Events::TEST_BEFORE => 'beforeTest',
Events::TEST_AFTER => 'afterTest',
Events::TEST_END => 'endTest',
Events::STEP_BEFORE => 'beforeStep',
Events::TEST_FAIL => 'testFail',
Events::TEST_ERROR => 'testError',
Events::TEST_INCOMPLETE => 'testIncomplete',
Events::TEST_SKIPPED => 'testSkipped',
];
protected $logHandler;
/**
* @var \Monolog\Logger
*/
protected static $logger;
protected $path;
protected $config = ['max_files' => 3];
public function _initialize()
{
if (!class_exists('\Monolog\Logger')) {
throw new ConfigurationException("Logger extension requires Monolog library to be installed");
}
$this->path = $this->getLogDir();
// internal log
$logHandler = new RotatingFileHandler($this->path . 'codeception.log', $this->config['max_files']);
$formatter = $logHandler->getFormatter();
if ($formatter instanceof LineFormatter) {
$formatter->ignoreEmptyContextAndExtra(true);
}
self::$logger = new \Monolog\Logger('Codeception');
self::$logger->pushHandler($logHandler);
}
public static function getLogger()
{
return self::$logger;
}
public function beforeSuite(SuiteEvent $e)
{
$suiteLogFile = str_replace('\\', '_', $e->getSuite()->getName()) . '.log';
$this->logHandler = new RotatingFileHandler($this->path . $suiteLogFile, $this->config['max_files']);
}
public function beforeTest(TestEvent $e)
{
self::$logger = new \Monolog\Logger(Descriptor::getTestFullName($e->getTest()));
self::$logger->pushHandler($this->logHandler);
self::$logger->info('------------------------------------');
self::$logger->info("STARTED: " . ucfirst(Descriptor::getTestAsString($e->getTest())));
}
public function afterTest(TestEvent $e)
{
}
public function endTest(TestEvent $e)
{
self::$logger->info("PASSED");
}
public function testFail(FailEvent $e)
{
self::$logger->alert($e->getFail()->getMessage());
self::$logger->info("# FAILED #");
}
public function testError(FailEvent $e)
{
self::$logger->alert($e->getFail()->getMessage());
self::$logger->info("# ERROR #");
}
public function testSkipped(FailEvent $e)
{
self::$logger->info("# Skipped #");
}
public function testIncomplete(FailEvent $e)
{
self::$logger->info("# Incomplete #");
}
public function beforeStep(StepEvent $e)
{
self::$logger->info((string) $e->getStep());
}
}
if (!function_exists('codecept_log')) {
function codecept_log()
{
return Logger::getLogger();
}
} else {
throw new ExtensionException('Codeception\Extension\Logger', "function 'codecept_log' already defined");
}
+240
View File
@@ -0,0 +1,240 @@
# Official Extensions
## DotReporter
[See Source](https://github.com/Codeception/Codeception/blob/4.0/ext/DotReporter.php)
DotReporter provides less verbose output for test execution.
Like PHPUnit printer it prints dots "." for successful testes and "F" for failures.
![](https://cloud.githubusercontent.com/assets/220264/26132800/4d23f336-3aab-11e7-81ba-2896a4c623d2.png)
```bash
..........
..........
..........
..........
..........
..........
..........
..........
Time: 2.07 seconds, Memory: 20.00MB
OK (80 tests, 124 assertions)
```
Enable this reporter with `--ext option`
```
codecept run --ext DotReporter
```
Failures and Errors are printed by a standard Codeception reporter.
Use this extension as an example for building custom reporters.
## Logger
[See Source](https://github.com/Codeception/Codeception/blob/4.0/ext/Logger.php)
Log suites/tests/steps using Monolog library.
Monolog should be installed additionally by Composer.
```
composer require monolog/monolog
```
Steps are logged into `tests/_output/codeception.log`
To enable this module add to your `codeception.yml`:
``` yaml
extensions:
enabled: [Codeception\Extension\Logger]
```
#### Config
* `max_files` (default: 3) - how many log files to keep
## Recorder
[See Source](https://github.com/Codeception/Codeception/blob/4.0/ext/Recorder.php)
Saves a screenshot of each step in acceptance tests and shows them as a slideshow on one HTML page (here's an [example](https://codeception.com/images/recorder.gif))
Activated only for suites with WebDriver module enabled.
The screenshots are saved to `tests/_output/record_*` directories, open `index.html` to see them as a slideshow.
#### Installation
Add this to the list of enabled extensions in `codeception.yml` or `acceptance.suite.yml`:
``` yaml
extensions:
enabled:
- Codeception\Extension\Recorder
```
#### Configuration
* `delete_successful` (default: true) - delete screenshots for successfully passed tests (i.e. log only failed and errored tests).
* `module` (default: WebDriver) - which module for screenshots to use. Set `AngularJS` if you want to use it with AngularJS module. Generally, the module should implement `Codeception\Lib\Interfaces\ScreenshotSaver` interface.
* `ignore_steps` (default: []) - array of step names that should not be recorded (given the step passed), * wildcards supported. Meta steps can also be ignored.
* `success_color` (default: success) - bootstrap values to be used for color representation for passed tests
* `failure_color` (default: danger) - bootstrap values to be used for color representation for failed tests
* `error_color` (default: dark) - bootstrap values to be used for color representation for scenarios where there's an issue occurred while generating a recording
* `delete_orphaned` (default: false) - delete recording folders created via previous runs
* `include_microseconds` (default: false) - enable microsecond precision for recorded step time details
#### Examples:
``` yaml
extensions:
enabled:
- Codeception\Extension\Recorder:
module: AngularJS # enable for Angular
delete_successful: false # keep screenshots of successful tests
ignore_steps: [have, grab*]
```
#### Skipping recording of steps with annotations
It is also possible to skip recording of steps for specified tests by using the @skipRecording annotation.
```php
/**
* @skipRecording login
* @skipRecording amOnUrl
*\/
public function testLogin(AcceptanceTester $I)
{
$I->login();
$I->amOnUrl('https://codeception.com');
}
```
## RunBefore
[See Source](https://github.com/Codeception/Codeception/blob/4.0/ext/RunBefore.php)
Extension for execution of some processes before running tests.
Processes can be independent and dependent.
Independent processes run independently of each other.
Dependent processes run sequentially one by one.
Can be configured in suite config:
```yaml
# acceptance.suite.yml
extensions:
enabled:
- Codeception\Extension\RunBefore:
- independent_process_1
-
- dependent_process_1_1
- dependent_process_1_2
- independent_process_2
-
- dependent_process_2_1
- dependent_process_2_2
```
HINT: you can use different configurations per environment.
## RunFailed
[See Source](https://github.com/Codeception/Codeception/blob/4.0/ext/RunFailed.php)
Saves failed tests into tests/log/failed in order to rerun failed tests.
To rerun failed tests just run the `failed` group:
```
php codecept run -g failed
```
To change failed group name add:
```
--override "extensions: config: Codeception\Extension\RunFailed: fail-group: another_group1"
```
Remember: if you run tests and they generated custom-named fail group, to run this group, you should add override too
Starting from Codeception 2.1 **this extension is enabled by default**.
``` yaml
extensions:
enabled: [Codeception\Extension\RunFailed]
```
On each execution failed tests are logged and saved into `tests/_output/failed` file.
## RunProcess
[See Source](https://github.com/Codeception/Codeception/blob/4.0/ext/RunProcess.php)
Extension to start and stop processes per suite.
Can be used to start/stop selenium server, chromedriver, phantomjs, mailcatcher, etc.
Can be configured in suite config:
```yaml
# acceptance.suite.yml
extensions:
enabled:
- Codeception\Extension\RunProcess:
- chromedriver
```
Multiple parameters can be passed as array:
```yaml
# acceptance.suite.yml
extensions:
enabled:
- Codeception\Extension\RunProcess:
- php -S 127.0.0.1:8000 -t tests/data/app
- java -jar ~/selenium-server.jar
```
In the end of a suite all launched processes will be stopped.
To wait for the process to be launched use `sleep` option.
In this case you need configuration to be specified as object:
```yaml
extensions:
enabled:
- Codeception\Extension\RunProcess:
0: java -jar ~/selenium-server.jar
1: mailcatcher
sleep: 5 # wait 5 seconds for processes to boot
```
HINT: you can use different configurations per environment.
## SimpleReporter
[See Source](https://github.com/Codeception/Codeception/blob/4.0/ext/SimpleReporter.php)
This extension demonstrates how you can implement console output of your own.
Recommended to be used for development purposes only.
+652
View File
@@ -0,0 +1,652 @@
<?php
namespace Codeception\Extension;
use Codeception\Event\StepEvent;
use Codeception\Event\TestEvent;
use Codeception\Events;
use Codeception\Exception\ExtensionException;
use Codeception\Lib\Interfaces\ScreenshotSaver;
use Codeception\Module\WebDriver;
use Codeception\Step;
use Codeception\Step\Comment as CommentStep;
use Codeception\Test\Descriptor;
use Codeception\Util\FileSystem;
use Codeception\Util\Template;
/**
* Saves a screenshot of each step in acceptance tests and shows them as a slideshow on one HTML page (here's an [example](https://codeception.com/images/recorder.gif))
* Activated only for suites with WebDriver module enabled.
*
* The screenshots are saved to `tests/_output/record_*` directories, open `index.html` to see them as a slideshow.
*
* #### Installation
*
* Add this to the list of enabled extensions in `codeception.yml` or `acceptance.suite.yml`:
*
* ``` yaml
* extensions:
* enabled:
* - Codeception\Extension\Recorder
* ```
*
* #### Configuration
*
* * `delete_successful` (default: true) - delete screenshots for successfully passed tests (i.e. log only failed and errored tests).
* * `module` (default: WebDriver) - which module for screenshots to use. Set `AngularJS` if you want to use it with AngularJS module. Generally, the module should implement `Codeception\Lib\Interfaces\ScreenshotSaver` interface.
* * `ignore_steps` (default: []) - array of step names that should not be recorded (given the step passed), * wildcards supported. Meta steps can also be ignored.
* * `success_color` (default: success) - bootstrap values to be used for color representation for passed tests
* * `failure_color` (default: danger) - bootstrap values to be used for color representation for failed tests
* * `error_color` (default: dark) - bootstrap values to be used for color representation for scenarios where there's an issue occurred while generating a recording
* * `delete_orphaned` (default: false) - delete recording folders created via previous runs
* * `include_microseconds` (default: false) - enable microsecond precision for recorded step time details
*
* #### Examples:
*
* ``` yaml
* extensions:
* enabled:
* - Codeception\Extension\Recorder:
* module: AngularJS # enable for Angular
* delete_successful: false # keep screenshots of successful tests
* ignore_steps: [have, grab*]
* ```
* #### Skipping recording of steps with annotations
*
* It is also possible to skip recording of steps for specified tests by using the @skipRecording annotation.
*
* ```php
* /**
* * @skipRecording login
* * @skipRecording amOnUrl
* *\/
* public function testLogin(AcceptanceTester $I)
* {
* $I->login();
* $I->amOnUrl('https://codeception.com');
* }
* ```
*
*/
class Recorder extends \Codeception\Extension
{
/** @var array */
protected $config = [
'delete_successful' => true,
'module' => 'WebDriver',
'template' => null,
'animate_slides' => true,
'ignore_steps' => [],
'success_color' => 'success',
'failure_color' => 'danger',
'error_color' => 'dark',
'delete_orphaned' => false,
'include_microseconds' => false,
];
/** @var string */
protected $template = <<<EOF
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Recorder Result</title>
<!-- Bootstrap Core CSS -->
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" rel="stylesheet">
<style>
html,
body {
height: 100%;
}
.active {
height: 100%;
}
.carousel-caption {
background: rgba(0,0,0,0.8);
}
.carousel-caption.error {
background: #c0392b !important;
}
.carousel-item {
min-height: 100vh;
}
.fill {
width: 100%;
height: 100%;
text-align: center;
overflow-y: scroll;
background-position: top;
-webkit-background-size: cover;
-moz-background-size: cover;
background-size: cover;
-o-background-size: cover;
}
.gradient-right {
background:
linear-gradient(to left, rgba(0,0,0,.4), rgba(0,0,0,.0))
}
.gradient-left {
background:
linear-gradient(to right, rgba(0,0,0,.4), rgba(0,0,0,.0))
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="navbar-header">
<a class="navbar-brand" href="../records.html"></span>Recorded Tests</a>
</div>
<div class="collapse navbar-collapse" id="navbarText">
<ul class="navbar-nav mr-auto">
<span class="navbar-text">{{feature}}</span>
</ul>
<span class="navbar-text">{{test}}</span>
</div>
</nav>
<header id="steps" class="carousel slide" data-ride="carousel">
<!-- Indicators -->
<ol class="carousel-indicators">
{{indicators}}
</ol>
<!-- Wrapper for Slides -->
<div class="carousel-inner">
{{slides}}
</div>
<!-- Controls -->
<a class="carousel-control-prev gradient-left" href="#steps" role="button" data-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="false"></span>
<span class="sr-only">Previous</span>
</a>
<a class="carousel-control-next gradient-right" href="#steps" role="button" data-slide="next">
<span class="carousel-control-next-icon" aria-hidden="false"></span>
<span class="sr-only">Next</span>
</a>
</header>
<!-- jQuery -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"></script>
<!-- Script to Activate the Carousel -->
<script>
$('.carousel').carousel({
wrap: true,
interval: false
})
$(document).bind('keyup', function(e) {
if(e.keyCode==39){
jQuery('a.carousel-control.right').trigger('click');
}
else if(e.keyCode==37){
jQuery('a.carousel-control.left').trigger('click');
}
});
</script>
</body>
</html>
EOF;
/** @var string */
protected $indicatorTemplate = <<<EOF
<li data-target="#steps" data-slide-to="{{step}}" class="{{isActive}}"></li>
EOF;
/** @var string */
protected $indexTemplate = <<<EOF
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Recorder Results Index</title>
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="navbar-header">
<a class="navbar-brand" href="#">Recorded Tests
</a>
</div>
</nav>
<div class="container py-4">
<h1>Record #{{seed}}</h1>
<ul>
{{records}}
</ul>
</div>
</body>
</html>
EOF;
/** @var string */
protected $slidesTemplate = <<<EOF
<div class="carousel-item {{isActive}}">
<img class="mx-auto d-block mh-100" src="{{image}}">
<div class="carousel-caption {{isError}}">
<h5>{{caption}}</h5>
<p>Step finished at <span style="color: #3498db">"{{timeStamp}}"</span></p>
</div>
</div>
EOF;
/** @var array */
public static $events = [
Events::SUITE_BEFORE => 'beforeSuite',
Events::SUITE_AFTER => 'afterSuite',
Events::TEST_BEFORE => 'before',
Events::TEST_ERROR => 'persist',
Events::TEST_FAIL => 'persist',
Events::TEST_SUCCESS => 'cleanup',
Events::STEP_AFTER => 'afterStep',
];
/** @var WebDriver */
protected $webDriverModule;
/** @var string */
protected $dir;
/** @var array */
protected $slides = [];
/** @var int */
protected $stepNum = 0;
/** @var string */
protected $seed;
/** @var array */
protected $seeds;
/** @var array */
protected $recordedTests = [];
/** @var array */
protected $skipRecording = [];
/** @var array */
protected $errorMessages = [];
/** @var bool */
protected $colors;
/** @var bool */
protected $ansi;
/** @var array */
protected $timeStamps = [];
/** @var string */
private $dateFormat;
public function beforeSuite()
{
$this->webDriverModule = null;
if (!$this->hasModule($this->config['module'])) {
$this->writeln('Recorder is disabled, no available modules');
return;
}
$this->seed = uniqid();
$this->seeds[] = $this->seed;
$this->webDriverModule = $this->getModule($this->config['module']);
$this->skipRecording = [];
$this->errorMessages = [];
$this->dateFormat = $this->config['include_microseconds'] ? 'Y-m-d\TH:i:s.uP' : DATE_ATOM;
$this->ansi = !isset($this->options['no-ansi']);
$this->colors = !isset($this->options['no-colors']);
if (!$this->webDriverModule instanceof ScreenshotSaver) {
throw new ExtensionException(
$this,
'You should pass module which implements ' . ScreenshotSaver::class . ' interface'
);
}
$this->writeln(
sprintf(
'⏺ <bold>Recording</bold> ⏺ step-by-step screenshots will be saved to <info>%s</info>',
codecept_output_dir()
)
);
$this->writeln("Directory Format: <debug>record_{$this->seed}_{filename}_{testname}</debug> ----");
}
public function afterSuite()
{
if (!$this->webDriverModule) {
return;
}
$links = '';
if (count($this->slides)) {
foreach ($this->recordedTests as $suiteName => $suite) {
$links .= "<ul><li><b>{$suiteName}</b></li><ul>";
foreach ($suite as $fileName => $tests) {
$links .= "<li>{$fileName}</li><ul>";
foreach ($tests as $test) {
$links .= in_array($test['path'], $this->skipRecording, true)
? "<li class=\"text{$this->config['error_color']}\">{$test['name']}</li>\n"
: '<li class="text-' . $this->config[$test['status'] . '_color']
. "\"><a href='{$test['index']}'>{$test['name']}</a></li>\n";
}
$links .= '</ul>';
}
$links .= '</ul></ul>';
}
$indexHTML = (new Template($this->indexTemplate))
->place('seed', $this->seed)
->place('records', $links)
->produce();
try {
file_put_contents(codecept_output_dir() . 'records.html', $indexHTML);
} catch (\Exception $exception) {
$this->writeln(
"⏺ An exception occurred while saving records.html: <info>{$exception->getMessage()}</info>"
);
}
$this->writeln('⏺ Records saved into: <info>file://' . codecept_output_dir() . 'records.html</info>');
}
foreach ($this->errorMessages as $message) {
$this->writeln($message);
}
}
/**
* @param TestEvent $e
*/
public function before(TestEvent $e)
{
if (!$this->webDriverModule) {
return;
}
$this->dir = null;
$this->stepNum = 0;
$this->slides = [];
$this->timeStamps = [];
$this->dir = codecept_output_dir() . "record_{$this->seed}_{$this->getTestName($e)}";
$testPath = codecept_relative_path(Descriptor::getTestFullName($e->getTest()));
try {
!is_dir($this->dir) && !mkdir($this->dir) && !is_dir($this->dir);
} catch (\Exception $exception) {
$this->skipRecording[] = $testPath;
$this->appendErrorMessage(
$testPath,
"⏺ An exception occurred while creating directory: <info>{$this->dir}</info>"
);
}
}
/**
* @param TestEvent $e
*/
public function cleanup(TestEvent $e)
{
if ($this->config['delete_orphaned']) {
$recordingDirectories = [];
$directories = new \DirectoryIterator(codecept_output_dir());
// getting a list of currently present recording directories
foreach ($directories as $directory) {
preg_match('/^record_(.*?)_[^\n]+.php_[^\n]+$/', $directory->getFilename(), $match);
if (isset($match[1])) {
$recordingDirectories[$match[1]][] = codecept_output_dir() . $directory->getFilename();
}
}
// removing orphaned recording directories
foreach (array_diff(array_keys($recordingDirectories), $this->seeds) as $orphanedSeed) {
foreach ($recordingDirectories[$orphanedSeed] as $orphanedDirectory) {
FileSystem::deleteDir($orphanedDirectory);
}
}
}
if (!$this->webDriverModule || !$this->dir) {
return;
}
if (!$this->config['delete_successful']) {
$this->persist($e);
return;
}
// deleting successfully executed tests
FileSystem::deleteDir($this->dir);
}
/**
* @param TestEvent $e
*/
public function persist(TestEvent $e)
{
if (!$this->webDriverModule) {
return;
}
$indicatorHtml = '';
$slideHtml = '';
$testName = $this->getTestName($e);
$testPath = codecept_relative_path(Descriptor::getTestFullName($e->getTest()));
$dir = codecept_output_dir() . "record_{$this->seed}_$testName";
$status = 'success';
if (strcasecmp($this->dir, $dir) !== 0) {
$filename = str_pad(0, 3, '0', STR_PAD_LEFT) . '.png';
try {
!is_dir($dir) && !mkdir($dir) && !is_dir($dir);
$this->dir = $dir;
} catch (\Exception $exception) {
$this->skipRecording[] = $testPath;
$this->appendErrorMessage(
$testPath,
"⏺ An exception occurred while creating directory: <info>{$dir}</info>"
);
}
$this->slides = [];
$this->timeStamps = [];
$this->slides[$filename] = new Step\Action('encountered an unexpected error prior to the test execution');
$this->timeStamps[$filename] = (new \DateTime())->format($this->dateFormat);
$status = 'error';
try {
if ($this->webDriverModule->webDriver === null) {
throw new ExtensionException($this, 'Failed to save screenshot as webDriver is not set');
}
$this->webDriverModule->webDriver->takeScreenshot($this->dir . DIRECTORY_SEPARATOR . $filename);
} catch (\Exception $exception) {
$this->appendErrorMessage(
$testPath,
"⏺ Unable to capture a screenshot for <info>{$testPath}/before</info>"
);
}
}
if (!in_array($testPath, $this->skipRecording, true)) {
foreach ($this->slides as $i => $step) {
/** @var Step $step */
if ($step->hasFailed()) {
$status = 'failure';
}
$indicatorHtml .= (new Template($this->indicatorTemplate))
->place('step', (int)$i)
->place('isActive', (int)$i ? '' : 'active')
->produce();
$slideHtml .= (new Template($this->slidesTemplate))
->place('image', $i)
->place('caption', $step->getHtml('#3498db'))
->place('isActive', (int)$i ? '' : 'active')
->place('isError', $status === 'success' ? '' : 'error')
->place('timeStamp', $this->timeStamps[$i])
->produce();
}
$html = (new Template($this->template))
->place('indicators', $indicatorHtml)
->place('slides', $slideHtml)
->place('feature', ucfirst($e->getTest()->getFeature()))
->place('test', Descriptor::getTestSignature($e->getTest()))
->place('carousel_class', $this->config['animate_slides'] ? ' slide' : '')
->produce();
$indexFile = $this->dir . DIRECTORY_SEPARATOR . 'index.html';
$environment = $e->getTest()->getMetadata()->getCurrent('env') ?: '';
$suite = ucfirst(basename(\dirname($e->getTest()->getMetadata()->getFilename())));
$testName = basename($e->getTest()->getMetadata()->getFilename());
try {
file_put_contents($indexFile, $html);
} catch (\Exception $exception) {
$this->skipRecording[] = $testPath;
$this->appendErrorMessage(
$testPath,
"⏺ An exception occurred while saving index.html for <info>{$testPath}: "
. "{$exception->getMessage()}</info>"
);
}
$this->recordedTests["{$suite} ({$environment})"][$testName][] = [
'name' => $e->getTest()->getMetadata()->getName(),
'path' => $testPath,
'status' => $status,
'index' => substr($indexFile, strlen(codecept_output_dir())),
];
}
}
/**
* @param StepEvent $e
*/
public function afterStep(StepEvent $e)
{
if ($this->webDriverModule === null || $this->dir === null) {
return;
}
if ($e->getStep() instanceof CommentStep) {
return;
}
// only taking the ignore step into consideration if that step has passed
if ($this->isStepIgnored($e) && !$e->getStep()->hasFailed()) {
return;
}
$filename = str_pad($this->stepNum, 3, '0', STR_PAD_LEFT) . '.png';
try {
if ($this->webDriverModule->webDriver === null) {
throw new ExtensionException($this, 'Failed to save screenshot as webDriver is not set');
}
$this->webDriverModule->webDriver->takeScreenshot($this->dir . DIRECTORY_SEPARATOR . $filename);
} catch (\Exception $exception) {
$testPath = codecept_relative_path(Descriptor::getTestFullName($e->getTest()));
$this->appendErrorMessage(
$testPath,
"⏺ Unable to capture a screenshot for <info>{$testPath}/{$e->getStep()->getAction()}</info>"
);
}
$this->stepNum++;
$this->slides[$filename] = $e->getStep();
$this->timeStamps[$filename] = (new \DateTime())->format($this->dateFormat);
}
/**
* @param StepEvent $e
*
* @return bool
*/
protected function isStepIgnored(StepEvent $e)
{
$configIgnoredSteps = $this->config['ignore_steps'];
$annotationIgnoredSteps = $e->getTest()->getMetadata()->getParam('skipRecording');
$ignoredSteps = array_unique(
array_merge(
$configIgnoredSteps,
is_array($annotationIgnoredSteps) ? $annotationIgnoredSteps : []
)
);
foreach ($ignoredSteps as $stepPattern) {
$stepRegexp = '/^' . str_replace('*', '.*?', $stepPattern) . '$/i';
if (preg_match($stepRegexp, $e->getStep()->getAction())) {
return true;
}
if ($e->getStep()->getMetaStep() !== null &&
preg_match($stepRegexp, $e->getStep()->getMetaStep()->getAction())
) {
return true;
}
}
return false;
}
/**
* @param StepEvent|TestEvent $e
*
* @return string
*/
private function getTestName($e)
{
return basename($e->getTest()->getMetadata()->getFilename()) . '_' . preg_replace('/[^A-Za-z0-9\-\_]/', '_', $e->getTest()->getMetadata()->getName());
}
/**
* @param string $message
*/
protected function writeln($message)
{
parent::writeln(
$this->ansi
? $message
: trim(preg_replace('/[ ]{2,}/', ' ', str_replace('⏺', '', $message)))
);
}
/**
* @param string $testPath
* @param string $message
*/
private function appendErrorMessage($testPath, $message)
{
$this->errorMessages[$testPath] = array_merge(
array_key_exists($testPath, $this->errorMessages) ? $this->errorMessages[$testPath]: [],
[$message]
);
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
namespace Codeception\Extension;
use Codeception\Events;
use Codeception\Exception\ExtensionException;
use Codeception\Extension;
use Symfony\Component\Process\Process;
/**
* Extension for execution of some processes before running tests.
*
* Processes can be independent and dependent.
* Independent processes run independently of each other.
* Dependent processes run sequentially one by one.
*
* Can be configured in suite config:
*
* ```yaml
* # acceptance.suite.yml
* extensions:
* enabled:
* - Codeception\Extension\RunBefore:
* - independent_process_1
* -
* - dependent_process_1_1
* - dependent_process_1_2
* - independent_process_2
* -
* - dependent_process_2_1
* - dependent_process_2_2
* ```
*
* HINT: you can use different configurations per environment.
*/
class RunBefore extends Extension
{
protected $config = [];
protected static $events = [
Events::SUITE_BEFORE => 'runBefore'
];
/** @var array[] */
private $processes = [];
public function _initialize()
{
if (!class_exists('Symfony\Component\Process\Process')) {
throw new ExtensionException($this, 'symfony/process package is required');
}
}
public function runBefore()
{
$this->runProcesses();
$this->processMonitoring();
}
private function runProcesses()
{
foreach ($this->config as $item) {
if (is_array($item)) {
$currentCommand = array_shift($item);
$followingCommands = $item;
} else {
$currentCommand = $item;
$followingCommands = [];
}
$process = $this->runProcess($currentCommand);
$this->addProcessToMonitoring($process, $followingCommands);
}
}
/**
* @param string $command
* @return Process
*/
private function runProcess($command)
{
$this->output->debug('[RunBefore] Starting ' . $command);
if (method_exists(Process::class, 'fromShellCommandline')) {
//Symfony 4.2+
$process = Process::fromShellCommandline($command, $this->getRootDir());
} else {
$process = new Process($command, $this->getRootDir());
}
$process->start();
return $process;
}
/**
* @param string[] $followingCommands
*/
private function addProcessToMonitoring(Process $process, array $followingCommands)
{
$this->processes[] = [
'instance' => $process,
'following' => $followingCommands
];
}
/**
* @param int $index
*/
private function removeProcessFromMonitoring($index)
{
unset($this->processes[$index]);
}
private function processMonitoring()
{
while (count($this->processes) !== 0) {
$this->checkProcesses();
sleep(1);
}
}
private function checkProcesses()
{
foreach ($this->processes as $index => $process) {
/**
* @var Process $processInstance
*/
$processInstance = $process['instance'];
if (!$this->isRunning($processInstance)) {
if (!$processInstance->isSuccessful()) {
$this->output->debug('[RunBefore] Failed ' . $processInstance->getCommandLine());
$this->output->writeln('<error>' . $processInstance->getErrorOutput() . '</error>');
exit(1);
}
$this->output->debug('[RunBefore] Completed ' . $processInstance->getCommandLine());
$this->runFollowingCommand($process['following']);
$this->removeProcessFromMonitoring($index);
}
}
}
/**
* @param string[] $followingCommands
*/
private function runFollowingCommand(array $followingCommands)
{
if (count($followingCommands) > 0) {
$process = $this->runProcess(array_shift($followingCommands));
$this->addProcessToMonitoring($process, $followingCommands);
}
}
private function isRunning(Process $process)
{
if ($process->isRunning()) {
return true;
}
return false;
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace Codeception\Extension;
use Codeception\Event\PrintResultEvent;
use Codeception\Events;
use Codeception\Extension;
use Codeception\Test\Descriptor;
/**
* Saves failed tests into tests/_output/failed in order to rerun failed tests.
*
* To rerun failed tests just run the `failed` group:
*
* ```
* php codecept run -g failed
* ```
*
* To change failed group name add:
* ```
* --override "extensions: config: Codeception\Extension\RunFailed: fail-group: another_group1"
* ```
* Remember: if you run tests and they generated custom-named fail group, to run this group, you should add override too
*
* Starting from Codeception 2.1 **this extension is enabled by default**.
*
* ``` yaml
* extensions:
* enabled: [Codeception\Extension\RunFailed]
* ```
*
* On each execution failed tests are logged and saved into `tests/_output/failed` file.
*/
class RunFailed extends Extension
{
public static $events = [
Events::RESULT_PRINT_AFTER => 'saveFailed'
];
/** @var string filename/groupname for failed tests */
protected $group = 'failed';
public function _initialize()
{
if (array_key_exists('fail-group', $this->config) && $this->config['fail-group']) {
$this->group = $this->config['fail-group'];
}
$logPath = str_replace($this->getRootDir(), '', $this->getLogDir()); // get local path to logs
$this->_reconfigure(['groups' => [$this->group => $logPath . $this->group]]);
}
public function saveFailed(PrintResultEvent $e)
{
$file = $this->getLogDir() . $this->group;
$result = $e->getResult();
if ($result->wasSuccessful()) {
if (is_file($file)) {
unlink($file);
}
return;
}
$output = [];
foreach ($result->failures() as $fail) {
$output[] = $this->localizePath(Descriptor::getTestFullName($fail->failedTest()));
}
foreach ($result->errors() as $fail) {
$output[] = $this->localizePath(Descriptor::getTestFullName($fail->failedTest()));
}
file_put_contents($file, implode("\n", $output));
}
protected function localizePath($path)
{
$root = realpath($this->getRootDir()) . DIRECTORY_SEPARATOR;
if (substr($path, 0, strlen($root)) == $root) {
return substr($path, strlen($root));
}
return $path;
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace Codeception\Extension;
use Codeception\Events;
use Codeception\Exception\ExtensionException;
use Codeception\Extension;
use Symfony\Component\Process\Process;
/**
* Extension to start and stop processes per suite.
* Can be used to start/stop selenium server, chromedriver, mailcatcher, etc.
*
* Can be configured in suite config:
*
* ```yaml
* # acceptance.suite.yml
* extensions:
* enabled:
* - Codeception\Extension\RunProcess:
* - chromedriver
* ```
*
* Multiple parameters can be passed as array:
*
* ```yaml
* # acceptance.suite.yml
*
* extensions:
* enabled:
* - Codeception\Extension\RunProcess:
* - php -S 127.0.0.1:8000 -t tests/data/app
* - java -jar ~/selenium-server.jar
* ```
*
* In the end of a suite all launched processes will be stopped.
*
* To wait for the process to be launched use `sleep` option.
* In this case you need configuration to be specified as object:
*
* ```yaml
* extensions:
* enabled:
* - Codeception\Extension\RunProcess:
* 0: java -jar ~/selenium-server.jar
* 1: mailcatcher
* sleep: 5 # wait 5 seconds for processes to boot
* ```
*
* HINT: you can use different configurations per environment.
*/
class RunProcess extends Extension
{
protected $config = ['sleep' => 0];
protected static $events = [
Events::SUITE_BEFORE => 'runProcess',
Events::SUITE_AFTER => 'stopProcess'
];
private $processes = [];
public function _initialize()
{
if (!class_exists('Symfony\Component\Process\Process')) {
throw new ExtensionException($this, 'symfony/process package is required');
}
}
public function runProcess()
{
$this->processes = [];
foreach ($this->config as $key => $command) {
if (!$command) {
continue;
}
if (!is_int($key)) {
continue; // configuration options
}
if (method_exists(Process::class, 'fromShellCommandline')) {
//Symfony 4.2+
$process = Process::fromShellCommandline($command, $this->getRootDir(), null, null, null);
} else {
$process = new Process($command, $this->getRootDir(), null, null, null);
}
$process->start();
$this->processes[] = $process;
$this->output->debug('[RunProcess] Starting '.$command);
}
sleep($this->config['sleep']);
}
public function __destruct()
{
$this->stopProcess();
}
public function stopProcess()
{
foreach (array_reverse($this->processes) as $process) {
/** @var $process Process **/
if (!$process->isRunning()) {
continue;
}
$this->output->debug('[RunProcess] Stopping ' . $process->getCommandLine());
$process->stop();
}
$this->processes = [];
}
/**
* Disable the deserialization of the class to prevent attacker executing
* code by leveraging the __destruct method.
*
* @see https://owasp.org/www-community/vulnerabilities/PHP_Object_Injection
*/
public function __sleep()
{
throw new \BadMethodCallException('Cannot serialize ' . __CLASS__);
}
/**
* Disable the deserialization of the class to prevent attacker executing
* code by leveraging the __destruct method.
*
* @see https://owasp.org/www-community/vulnerabilities/PHP_Object_Injection
*/
public function __wakeup()
{
throw new \BadMethodCallException('Cannot unserialize ' . __CLASS__);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace Codeception\Extension;
use Codeception\Event\TestEvent;
use Codeception\Events;
use Codeception\Extension;
use Codeception\Test\Descriptor;
/**
* This extension demonstrates how you can implement console output of your own.
* Recommended to be used for development purposes only.
*/
class SimpleReporter extends Extension
{
public function _initialize()
{
$this->options['silent'] = false; // turn on printing for this extension
$this->_reconfigure(['settings' => ['silent' => true]]); // turn off printing for everything else
}
// we are listening for events
public static $events = [
Events::SUITE_BEFORE => 'beforeSuite',
Events::TEST_END => 'after',
Events::TEST_SUCCESS => 'success',
Events::TEST_FAIL => 'fail',
Events::TEST_ERROR => 'error',
];
public function beforeSuite()
{
$this->writeln("");
}
public function success()
{
$this->write('[+] ');
}
public function fail()
{
$this->write('[-] ');
}
public function error()
{
$this->write('[E] ');
}
// we are printing test status and time taken
public function after(TestEvent $e)
{
$seconds_input = $e->getTime();
// stack overflow: https://stackoverflow.com/questions/16825240/how-to-convert-microtime-to-hhmmssuu
$seconds = (int)($milliseconds = (int)($seconds_input * 1000)) / 1000;
$time = ($seconds % 60) . (($milliseconds === 0) ? '' : '.' . $milliseconds);
$this->write(Descriptor::getTestSignature($e->getTest()));
$this->writeln(' (' . $time . 's)');
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
if (!function_exists('codecept_debug')) {
function codecept_debug($data)
{
\Codeception\Util\Debug::debug($data);
}
}
if (!function_exists('codecept_root_dir')) {
function codecept_root_dir($appendPath = '')
{
return \Codeception\Configuration::projectDir() . $appendPath;
}
}
if (!function_exists('codecept_output_dir')) {
function codecept_output_dir($appendPath = '')
{
return \Codeception\Configuration::outputDir() . $appendPath;
}
}
if (!function_exists('codecept_log_dir')) {
function codecept_log_dir($appendPath = '')
{
return \Codeception\Configuration::outputDir() . $appendPath;
}
}
if (!function_exists('codecept_data_dir')) {
function codecept_data_dir($appendPath = '')
{
return \Codeception\Configuration::dataDir() . $appendPath;
}
}
if (!function_exists('codecept_relative_path')) {
function codecept_relative_path($path)
{
return \Codeception\Util\PathResolver::getRelativeDir(
$path,
\Codeception\Configuration::projectDir(),
DIRECTORY_SEPARATOR
);
}
}
if (!function_exists('codecept_absolute_path')) {
/**
* If $path is absolute, it will be returned without changes.
* If $path is relative, it will be passed to `codecept_root_dir()` function
* to make it absolute.
*
* @param string $path
* @return string the absolute path
*/
function codecept_absolute_path($path)
{
return codecept_is_path_absolute($path) ? $path : codecept_root_dir($path);
}
}
if (!function_exists('codecept_is_path_absolute')) {
/**
* Check whether the given $path is absolute.
*
* @param string $path
* @return bool
* @since 2.4.4
*/
function codecept_is_path_absolute($path)
{
return \Codeception\Util\PathResolver::isPathAbsolute($path);
}
}
+3
View File
@@ -0,0 +1,3 @@
<?php
require __DIR__ . '/../app.php';
+43
View File
@@ -0,0 +1,43 @@
<?php
// @codingStandardsIgnoreStart
namespace {
\Codeception\PHPUnit\Init::init();
}
namespace Symfony\Component\CssSelector {
if (!class_exists('Symfony\Component\CssSelector\CssSelectorConverter')) {
class CssSelectorConverter {
function toXPath($cssExpr, $prefix = 'descendant-or-self::') {
return CssSelector::toXPath($cssExpr, $prefix);
}
}
}
}
// prefering old names
namespace Codeception\TestCase {
class Test extends \Codeception\Test\Unit {
}
}
namespace Codeception\Platform {
abstract class Group extends \Codeception\GroupObject
{
}
abstract class Extension extends \Codeception\Extension
{
}
}
namespace {
class_alias('Codeception\TestInterface', 'Codeception\TestCase');
//Compatibility with Symfony 5
if (!class_exists('Symfony\Component\EventDispatcher\Event') && class_exists('Symfony\Contracts\EventDispatcher\Event')) {
class_alias('Symfony\Contracts\EventDispatcher\Event', 'Symfony\Component\EventDispatcher\Event');
}
}
@@ -0,0 +1,58 @@
<?php
namespace Codeception;
use Codeception\Lib\Actor\Shared\Comment;
use Codeception\Step\Executor;
use Codeception\Lib\Actor\Shared\Pause;
abstract class Actor
{
use Comment;
use Pause;
/**
* @var \Codeception\Scenario
*/
protected $scenario;
public function __construct(Scenario $scenario)
{
$this->scenario = $scenario;
}
/**
* @return \Codeception\Scenario
*/
protected function getScenario()
{
return $this->scenario;
}
public function wantToTest($text)
{
$this->wantTo('test ' . $text);
}
public function wantTo($text)
{
$this->scenario->setFeature($text);
}
public function __call($method, $arguments)
{
$class = get_class($this);
throw new \RuntimeException("Call to undefined method $class::$method");
}
/**
* Lazy-execution given anonymous function
* @param $callable \Closure
* @return $this
*/
public function execute($callable)
{
$this->scenario->addStep(new Executor($callable, []));
$callable();
return $this;
}
}
@@ -0,0 +1,192 @@
<?php
namespace Codeception;
use Codeception\Exception\ConfigurationException;
use Symfony\Component\Console\Application as BaseApplication;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\OutputInterface;
class Application extends BaseApplication
{
/**
* @var ArgvInput
*/
protected $coreArguments = null;
/**
* Register commands from config file
*
* extensions:
* commands:
* - Project\Command\MyCustomCommand
*
*/
public function registerCustomCommands()
{
try {
$this->readCustomCommandsFromConfig();
} catch (ConfigurationException $e) {
if ($e->getCode() === 404) {
return;
}
$this->renderExceptionWrapper($e, new ConsoleOutput());
exit(1);
} catch (\Exception $e) {
$this->renderExceptionWrapper($e, new ConsoleOutput());
exit(1);
}
}
public function renderExceptionWrapper(\Exception $e, OutputInterface $output)
{
if (method_exists('Symfony\Component\Console\Application', 'renderException')) {
//Symfony 5
parent::renderException($e, $output);
} else {
parent::renderThrowable($e, $output);
}
}
/**
* Search custom commands and register them.
*
* @throws ConfigurationException
*/
protected function readCustomCommandsFromConfig()
{
$this->getCoreArguments(); // Maybe load outside configfile
$config = Configuration::config();
if (empty($config['extensions']['commands'])) {
return;
}
foreach ($config['extensions']['commands'] as $commandClass) {
$commandName = $this->getCustomCommandName($commandClass);
$this->add(new $commandClass($commandName));
}
}
/**
* Validate and get the name of the command
*
* @param CustomCommandInterface $commandClass
*
* @throws ConfigurationException
*
* @return string
*/
protected function getCustomCommandName($commandClass)
{
if (!class_exists($commandClass)) {
throw new ConfigurationException("Extension: Command class $commandClass not found");
}
$interfaces = class_implements($commandClass);
if (!in_array('Codeception\CustomCommandInterface', $interfaces)) {
throw new ConfigurationException("Extension: Command {$commandClass} must implement " .
"the interface `Codeception\\CustomCommandInterface`");
}
return $commandClass::getCommandName();
}
/**
* To cache Class ArgvInput
*
* @inheritDoc
*/
public function run(InputInterface $input = null, OutputInterface $output = null)
{
if ($input === null) {
$input = $this->getCoreArguments();
}
if (!ini_get('register_argc_argv')) {
throw new ConfigurationException('register_argc_argv must be set to On for running Codeception');
}
return parent::run($input, $output);
}
/**
* Add global a --config option.
*
* @return InputDefinition
*/
protected function getDefaultInputDefinition()
{
$inputDefinition = parent::getDefaultInputDefinition();
$inputDefinition->addOption(
new InputOption('config', 'c', InputOption::VALUE_OPTIONAL, 'Use custom path for config')
);
return $inputDefinition;
}
/**
* Search for --config Option and if found will be loaded
*
* example:
* -c file.yml|dir
* -cfile.yml|dir
* --config file.yml|dir
* --config=file.yml|dir
*
* @return ArgvInput
*/
protected function getCoreArguments()
{
if ($this->coreArguments !== null) {
return $this->coreArguments;
}
$argvWithoutConfig = [];
if (isset($_SERVER['argv'])) {
$argv = $_SERVER['argv'];
for ($i = 0; $i < count($argv); $i++) {
if (preg_match('/^(?:-([^c-]*)?c|--config(?:=|$))(.*)$/', $argv[$i], $match)) {
if (!empty($match[2])) { //same index
$this->preloadConfiguration($match[2]);
} elseif (isset($argv[$i + 1])) { //next index
$this->preloadConfiguration($argv[++$i]);
}
if (!empty($match[1])) {
$argvWithoutConfig[] = "-" . $match[1]; //rest commands
}
continue;
}
$argvWithoutConfig[] = $argv[$i];
}
}
return $this->coreArguments = new ArgvInput($argvWithoutConfig);
}
/**
* Pre load Configuration, the config option is use.
*
* @param string $configFile Path to Configuration
*
* @throws ConfigurationException
*/
protected function preloadConfiguration($configFile)
{
try {
Configuration::config($configFile);
} catch (ConfigurationException $e) {
if ($e->getCode() == 404) {
throw new ConfigurationException("Your configuration file `{$configFile}` could not be found.", 405);
}
throw $e;
}
}
}
@@ -0,0 +1,255 @@
<?php
namespace Codeception;
use Codeception\Event\DispatcherWrapper;
use Codeception\Exception\ConfigurationException;
use Codeception\Subscriber\ExtensionLoader;
use Symfony\Component\EventDispatcher\EventDispatcher;
class Codecept
{
use DispatcherWrapper;
const VERSION = '4.2.2';
/**
* @var \Codeception\PHPUnit\Runner
*/
protected $runner;
/**
* @var \PHPUnit\Framework\TestResult
*/
protected $result;
/**
* @var \Codeception\CodeCoverage
*/
protected $coverage;
/**
* @var \Symfony\Component\EventDispatcher\EventDispatcher
*/
protected $dispatcher;
/**
* @var ExtensionLoader
*/
protected $extensionLoader;
/**
* @var array
*/
protected $options = [
'silent' => false,
'debug' => false,
'steps' => false,
'html' => false,
'xml' => false,
'phpunit-xml' => false,
'no-redirect' => true,
'json' => false,
'tap' => false,
'report' => false,
'colors' => false,
'coverage' => false,
'coverage-xml' => false,
'coverage-html' => false,
'coverage-text' => false,
'coverage-crap4j' => false,
'coverage-cobertura' => false,
'coverage-phpunit'=> false,
'groups' => null,
'excludeGroups' => null,
'filter' => null,
'env' => null,
'fail-fast' => false,
'ansi' => true,
'verbosity' => 1,
'interactive' => true,
'no-rebuild' => false,
'quiet' => false,
];
protected $config = [];
/**
* @var array
*/
protected $extensions = [];
public function __construct($options = [])
{
$this->result = new \PHPUnit\Framework\TestResult;
$this->dispatcher = new EventDispatcher();
$this->extensionLoader = new ExtensionLoader($this->dispatcher);
$baseOptions = $this->mergeOptions($options);
$this->extensionLoader->bootGlobalExtensions($baseOptions); // extensions may override config
$this->config = Configuration::config();
$this->options = $this->mergeOptions($options); // options updated from config
$this->registerSubscribers();
$this->registerPHPUnitListeners();
$this->registerPrinter();
}
/**
* Merges given options with default values and current configuration
*
* @param array $options options
* @return array
* @throws ConfigurationException
*/
protected function mergeOptions($options)
{
$config = Configuration::config();
$baseOptions = array_merge($this->options, $config['settings']);
return array_merge($baseOptions, $options);
}
protected function registerPHPUnitListeners()
{
$listener = new PHPUnit\Listener($this->dispatcher);
$this->result->addListener($listener);
}
public function registerSubscribers()
{
// required
$this->dispatcher->addSubscriber(new Subscriber\GracefulTermination());
$this->dispatcher->addSubscriber(new Subscriber\ErrorHandler());
$this->dispatcher->addSubscriber(new Subscriber\Dependencies());
$this->dispatcher->addSubscriber(new Subscriber\Bootstrap());
$this->dispatcher->addSubscriber(new Subscriber\PrepareTest());
$this->dispatcher->addSubscriber(new Subscriber\Module());
$this->dispatcher->addSubscriber(new Subscriber\BeforeAfterTest());
// optional
if (!$this->options['no-rebuild']) {
$this->dispatcher->addSubscriber(new Subscriber\AutoRebuild());
}
if (!$this->options['silent']) {
$this->dispatcher->addSubscriber(new Subscriber\Console($this->options));
}
if ($this->options['fail-fast']) {
$this->dispatcher->addSubscriber(new Subscriber\FailFast());
}
if ($this->options['coverage']) {
$this->dispatcher->addSubscriber(new Coverage\Subscriber\Local($this->options));
$this->dispatcher->addSubscriber(new Coverage\Subscriber\LocalServer($this->options));
$this->dispatcher->addSubscriber(new Coverage\Subscriber\RemoteServer($this->options));
$this->dispatcher->addSubscriber(new Coverage\Subscriber\Printer($this->options));
}
$this->dispatcher->addSubscriber($this->extensionLoader);
$this->extensionLoader->registerGlobalExtensions();
}
public function run($suite, $test = null, array $config = null)
{
ini_set(
'memory_limit',
isset($this->config['settings']['memory_limit']) ? $this->config['settings']['memory_limit'] : '1024M'
);
$config = $config ?: Configuration::config();
$config = Configuration::suiteSettings($suite, $config);
$selectedEnvironments = $this->options['env'];
if (!$selectedEnvironments || empty($config['env'])) {
$this->runSuite($config, $suite, $test);
return;
}
// Iterate over all unique environment sets and runs the given suite with each of the merged configurations.
foreach (array_unique($selectedEnvironments) as $envList) {
$envSet = explode(',', $envList);
$suiteEnvConfig = $config;
// contains a list of the environments used in this suite configuration env set.
$envConfigs = [];
foreach ($envSet as $currentEnv) {
if (isset($config['env'])) {
// The $settings['env'] actually contains all parsed configuration files as a
// filename => filecontents key-value array. If there is no configuration file for the
// $currentEnv the merge will be skipped.
if (!array_key_exists($currentEnv, $config['env'])) {
return;
}
// Merge configuration consecutively with already build configuration
$suiteEnvConfig = Configuration::mergeConfigs($suiteEnvConfig, $config['env'][$currentEnv]);
$envConfigs[] = $currentEnv;
}
}
$suiteEnvConfig['current_environment'] = implode(',', $envConfigs);
if (empty($suiteEnvConfig)) {
continue;
}
$suiteToRun = $suite;
if (!empty($envList)) {
$suiteToRun .= ' (' . implode(', ', $envSet) . ')';
}
$this->runSuite($suiteEnvConfig, $suiteToRun, $test);
}
}
public function runSuite($settings, $suite, $test = null)
{
$suiteManager = new SuiteManager($this->dispatcher, $suite, $settings);
$suiteManager->initialize();
srand($this->options['seed']);
$suiteManager->loadTests($test);
srand();
$suiteManager->run($this->runner, $this->result, $this->options);
return $this->result;
}
public static function versionString()
{
return 'Codeception PHP Testing Framework v' . self::VERSION;
}
public function printResult()
{
$result = $this->getResult();
$result->flushListeners();
$printer = $this->runner->getPrinter();
$printer->printResult($result);
$this->dispatch($this->dispatcher, Events::RESULT_PRINT_AFTER, new Event\PrintResultEvent($result, $printer));
}
/**
* @return \PHPUnit\Framework\TestResult
*/
public function getResult()
{
return $this->result;
}
public function getOptions()
{
return $this->options;
}
/**
* @return EventDispatcher
*/
public function getDispatcher()
{
return $this->dispatcher;
}
protected function registerPrinter()
{
$printer = new PHPUnit\ResultPrinter\UI($this->dispatcher, $this->options);
$this->runner = new PHPUnit\Runner();
$this->runner->setPrinter($printer);
}
}
@@ -0,0 +1,58 @@
<?php
namespace Codeception\Command;
use Codeception\Template\Bootstrap as BootstrapTemplate;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Creates default config, tests directory and sample suites for current project.
* Use this command to start building a test suite.
*
* By default it will create 3 suites **acceptance**, **functional**, and **unit**.
*
* * `codecept bootstrap` - creates `tests` dir and `codeception.yml` in current dir.
* * `codecept bootstrap --empty` - creates `tests` dir without suites
* * `codecept bootstrap --namespace Frontend` - creates tests, and use `Frontend` namespace for actor classes and helpers.
* * `codecept bootstrap --actor Wizard` - sets actor as Wizard, to have `TestWizard` actor in tests.
* * `codecept bootstrap path/to/the/project` - provide different path to a project, where tests should be placed
*
*/
class Bootstrap extends Command
{
protected function configure()
{
$this->setDefinition(
[
new InputArgument('path', InputArgument::OPTIONAL, 'custom installation dir', null),
new InputOption(
'namespace',
's',
InputOption::VALUE_OPTIONAL,
'Namespace to add for actor classes and helpers'
),
new InputOption('actor', 'a', InputOption::VALUE_OPTIONAL, 'Custom actor instead of Tester'),
new InputOption('empty', 'e', InputOption::VALUE_NONE, 'Don\'t create standard suites')
]
);
}
public function getDescription()
{
return "Creates default test suites and generates all required files";
}
public function execute(InputInterface $input, OutputInterface $output)
{
$bootstrap = new BootstrapTemplate($input, $output);
if ($input->getArgument('path')) {
$bootstrap->initDir($input->getArgument('path'));
}
$bootstrap->setup();
return 0;
}
}
@@ -0,0 +1,109 @@
<?php
namespace Codeception\Command;
use Codeception\Configuration;
use Codeception\Lib\Generator\Actions as ActionsGenerator;
use Codeception\Lib\Generator\Actor as ActorGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Generates Actor classes (initially Guy classes) from suite configs.
* Starting from Codeception 2.0 actor classes are auto-generated. Use this command to generate them manually.
*
* * `codecept build`
* * `codecept build path/to/project`
*
*/
class Build extends Command
{
use Shared\Config;
use Shared\FileSystem;
protected $inheritedMethodTemplate = ' * @method void %s(%s)';
/**
* @var OutputInterface
*/
protected $output;
public function getDescription()
{
return 'Generates base classes for all suites';
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$this->output = $output;
$this->buildActorsForConfig();
return 0;
}
private function buildActor(array $settings)
{
$actorGenerator = new ActorGenerator($settings);
$this->output->writeln(
'<info>' . Configuration::config()['namespace'] . '\\' . $actorGenerator->getActorName()
. "</info> includes modules: " . implode(', ', $actorGenerator->getModules())
);
$content = $actorGenerator->produce();
$file = $this->createDirectoryFor(
Configuration::supportDir(),
$settings['actor']
) . $this->getShortClassName($settings['actor']);
$file .= '.php';
return $this->createFile($file, $content);
}
private function buildActions(array $settings)
{
$actionsGenerator = new ActionsGenerator($settings);
$content = $actionsGenerator->produce();
$this->output->writeln(
" -> {$settings['actor']}Actions.php generated successfully. "
. $actionsGenerator->getNumMethods() . " methods added"
);
$file = $this->createDirectoryFor(Configuration::supportDir() . '_generated', $settings['actor']);
$file .= $this->getShortClassName($settings['actor']) . 'Actions.php';
return $this->createFile($file, $content, true);
}
private function buildSuiteActors()
{
$suites = $this->getSuites();
if (!empty($suites)) {
$this->output->writeln("<info>Building Actor classes for suites: " . implode(', ', $suites) . '</info>');
}
foreach ($suites as $suite) {
$settings = $this->getSuiteConfig($suite);
if (!$settings['actor']) {
continue; // no actor
}
$this->buildActions($settings);
$actorBuilt = $this->buildActor($settings);
if ($actorBuilt) {
$this->output->writeln("{$settings['actor']}.php created.");
}
}
}
protected function buildActorsForConfig($configFile = null)
{
$config = $this->getGlobalConfig($configFile);
$dir = Configuration::projectDir();
$this->buildSuiteActors();
foreach ($config['include'] as $subConfig) {
$this->output->writeln("\n<comment>Included Configuration: $subConfig</comment>");
$this->buildActorsForConfig($dir . DIRECTORY_SEPARATOR . $subConfig);
}
}
}
@@ -0,0 +1,47 @@
<?php
namespace Codeception\Command;
use Codeception\Configuration;
use Codeception\Util\FileSystem;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Recursively cleans `output` directory and generated code.
*
* * `codecept clean`
*
*/
class Clean extends Command
{
use Shared\Config;
public function getDescription()
{
return 'Recursively cleans log and generated code';
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$projectDir = Configuration::projectDir();
$this->cleanProjectsRecursively($output, $projectDir);
$output->writeln("Done");
return 0;
}
private function cleanProjectsRecursively(OutputInterface $output, $projectDir)
{
$config = Configuration::config($projectDir);
$logDir = Configuration::logDir();
$output->writeln("<info>Cleaning up output " . $logDir . "...</info>");
FileSystem::doEmptyDir($logDir);
$subProjects = $config['include'];
foreach ($subProjects as $subProject) {
$subProjectDir = $projectDir . $subProject;
$this->cleanProjectsRecursively($output, $subProjectDir);
}
}
}
@@ -0,0 +1,86 @@
<?php
namespace Codeception\Command;
use Codeception\Configuration;
use Stecman\Component\Symfony\Console\BashCompletion\Completion as ConsoleCompletion;
use Stecman\Component\Symfony\Console\BashCompletion\CompletionCommand;
use Stecman\Component\Symfony\Console\BashCompletion\CompletionHandler;
use Stecman\Component\Symfony\Console\BashCompletion\Completion\ShellPathCompletion;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
// phpcs:ignoreFile PSR1.Files.SideEffects.FoundWithSymbols
if (!class_exists(ConsoleCompletion::class)) {
echo "Please install `stecman/symfony-console-completion\n` to enable auto completion";
return;
}
class Completion extends CompletionCommand
{
protected function configureCompletion(CompletionHandler $handler)
{
// Can't set for all commands, because it wouldn't work well with generate:suite
$suiteCommands = [
'run',
'config:validate',
'console',
'dry-run',
'generate:cept',
'generate:cest',
'generate:feature',
'generate:phpunit',
'generate:scenarios',
'generate:stepobject',
'generate:test',
'gherkin:snippets',
'gherkin:steps'
];
foreach ($suiteCommands as $suiteCommand) {
$handler->addHandler(new ConsoleCompletion(
$suiteCommand,
'suite',
ConsoleCompletion::TYPE_ARGUMENT,
Configuration::suites()
));
}
$handler->addHandlers([
new ShellPathCompletion(
ConsoleCompletion::ALL_COMMANDS,
'path',
ConsoleCompletion::TYPE_ARGUMENT
),
new ShellPathCompletion(
ConsoleCompletion::ALL_COMMANDS,
'test',
ConsoleCompletion::TYPE_ARGUMENT
),
]);
}
protected function execute(InputInterface $input, OutputInterface $output)
{
if ($input->getOption('generate-hook') && $input->getOption('use-vendor-bin')) {
global $argv;
$argv[0] = 'vendor/bin/' . basename($argv[0]);
}
parent::execute($input, $output);
return 0;
}
protected function createDefinition()
{
$definition = parent::createDefinition();
$definition->addOption(new InputOption(
'use-vendor-bin',
null,
InputOption::VALUE_NONE,
'Use the vendor bin for autocompletion.'
));
return $definition;
}
}
@@ -0,0 +1,41 @@
<?php
namespace Codeception\Command;
use Codeception\Configuration;
use Stecman\Component\Symfony\Console\BashCompletion\Completion as ConsoleCompletion;
use Stecman\Component\Symfony\Console\BashCompletion\CompletionCommand;
use Stecman\Component\Symfony\Console\BashCompletion\CompletionHandler;
use Stecman\Component\Symfony\Console\BashCompletion\Completion\ShellPathCompletion;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class CompletionFallback extends Command
{
protected function configure()
{
$this
->setName('_completion')
->setDescription('BASH completion hook.')
->setHelp(<<<END
To enable BASH completion, install optional stecman/symfony-console-completion first:
<comment>composer require stecman/symfony-console-completion</comment>
END
);
// Hide this command from listing if supported
// Command::setHidden() was not available before Symfony 3.2.0
if (method_exists($this, 'setHidden')) {
$this->setHidden(true);
}
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$output->writeln("Install optional <comment>stecman/symfony-console-completion</comment>");
return 0;
}
}
@@ -0,0 +1,107 @@
<?php
namespace Codeception\Command;
use Codeception\Configuration;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Validates and prints Codeception config.
* Use it do debug Yaml configs
*
* Check config:
*
* * `codecept config`: check global config
* * `codecept config unit`: check suite config
*
* Load config:
*
* * `codecept config:validate -c path/to/another/config`: from another dir
* * `codecept config:validate -c another_config.yml`: from another config file
*
* Check overriding config values (like in `run` command)
*
* * `codecept config:validate -o "settings: shuffle: true"`: enable shuffle
* * `codecept config:validate -o "settings: lint: false"`: disable linting
* * `codecept config:validate -o "reporters: report: \Custom\Reporter" --report`: use custom reporter
*
*/
class ConfigValidate extends Command
{
use Shared\Config;
use Shared\Style;
protected function configure()
{
$this->setDefinition(
[
new InputArgument('suite', InputArgument::OPTIONAL, 'to show suite configuration'),
new InputOption('config', 'c', InputOption::VALUE_OPTIONAL, 'Use custom path for config'),
new InputOption('override', 'o', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Override config values'),
]
);
parent::configure();
}
public function getDescription()
{
return 'Validates and prints config to screen';
}
public function execute(InputInterface $input, OutputInterface $output)
{
$this->addStyles($output);
if ($suite = $input->getArgument('suite')) {
$output->write("Validating <bold>$suite</bold> config... ");
$config = $this->getSuiteConfig($suite);
$output->writeln("Ok");
$output->writeln("------------------------------\n");
$output->writeln("<info>$suite Suite Config</info>:\n");
$output->writeln($this->formatOutput($config));
return 0;
}
$output->write("Validating global config... ");
$config = $this->getGlobalConfig();
$output->writeln($input->getOption('override'));
if (count($input->getOption('override'))) {
$config = $this->overrideConfig($input->getOption('override'));
}
$suites = Configuration::suites();
$output->writeln("Ok");
$output->writeln("------------------------------\n");
$output->writeln("<info>Codeception Config</info>:\n");
$output->writeln($this->formatOutput($config));
$output->writeln('<info>Directories</info>:');
$output->writeln("<comment>codecept_root_dir()</comment> " . codecept_root_dir());
$output->writeln("<comment>codecept_output_dir()</comment> " . codecept_output_dir());
$output->writeln("<comment>codecept_data_dir()</comment> " . codecept_data_dir());
$output->writeln('');
$output->writeln("<info>Available suites</info>: " . implode(', ', $suites));
foreach ($suites as $suite) {
$output->write("Validating suite <bold>$suite</bold>... ");
$this->getSuiteConfig($suite);
$output->writeln('Ok');
}
$output->writeln("Execute <info>codecept config:validate [<suite>]</info> to see config for a suite");
return 0;
}
protected function formatOutput($config)
{
$output = print_r($config, true);
return preg_replace('~\[(.*?)\] =>~', "<fg=yellow>$1</fg=yellow> =>", $output);
}
}
@@ -0,0 +1,128 @@
<?php
namespace Codeception\Command;
use Codeception\Codecept;
use Codeception\Configuration;
use Codeception\Event\DispatcherWrapper;
use Codeception\Event\SuiteEvent;
use Codeception\Event\TestEvent;
use Codeception\Events;
use Codeception\Exception\ConfigurationException;
use Codeception\Lib\Console\Output;
use Codeception\Scenario;
use Codeception\SuiteManager;
use Codeception\Test\Cept;
use Codeception\Util\Debug;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
/**
* Try to execute test commands in run-time. You may try commands before writing the test.
*
* * `codecept console acceptance` - starts acceptance suite environment. If you use WebDriver you can manipulate browser with Codeception commands.
*/
class Console extends Command
{
use DispatcherWrapper;
protected $test;
protected $codecept;
protected $suite;
protected $output;
protected $actions = [];
protected function configure()
{
$this->setDefinition([
new InputArgument('suite', InputArgument::REQUIRED, 'suite to be executed'),
new InputOption('colors', '', InputOption::VALUE_NONE, 'Use colors in output'),
]);
parent::configure();
}
public function getDescription()
{
return 'Launches interactive test console';
}
public function execute(InputInterface $input, OutputInterface $output)
{
$suiteName = $input->getArgument('suite');
$this->output = $output;
$config = Configuration::config();
$settings = Configuration::suiteSettings($suiteName, $config);
$options = $input->getOptions();
$options['debug'] = true;
$options['silent'] = true;
$options['interactive'] = false;
$options['colors'] = true;
Debug::setOutput(new Output($options));
$this->codecept = new Codecept($options);
$dispatcher = $this->codecept->getDispatcher();
$suiteManager = new SuiteManager($dispatcher, $suiteName, $settings);
$suiteManager->initialize();
$this->suite = $suiteManager->getSuite();
$moduleContainer = $suiteManager->getModuleContainer();
$this->actions = array_keys($moduleContainer->getActions());
$this->test = new Cept(null, null);
$this->test->getMetadata()->setServices([
'dispatcher' => $dispatcher,
'modules' => $moduleContainer
]);
$scenario = new Scenario($this->test);
if (!$settings['actor']) {
throw new ConfigurationException("Interactive shell can't be started without an actor");
}
if (isset($config["namespace"])) {
$settings['actor'] = $config["namespace"] .'\\' . $settings['actor'];
}
$actor = $settings['actor'];
$I = new $actor($scenario);
$this->listenToSignals();
$output->writeln("<info>Interactive console started for suite $suiteName</info>");
$output->writeln("<info>Try Codeception commands without writing a test</info>");
$suiteEvent = new SuiteEvent($this->suite, $this->codecept->getResult(), $settings);
$this->dispatch($dispatcher, Events::SUITE_BEFORE, $suiteEvent);
$this->dispatch($dispatcher, Events::TEST_PARSED, new TestEvent($this->test));
$this->dispatch($dispatcher, Events::TEST_BEFORE, new TestEvent($this->test));
if (file_exists($settings['bootstrap'])) {
require $settings['bootstrap'];
}
$I->pause();
$this->dispatch($dispatcher, Events::TEST_AFTER, new TestEvent($this->test));
$this->dispatch($dispatcher, Events::SUITE_AFTER, new SuiteEvent($this->suite));
$output->writeln("<info>Bye-bye!</info>");
return 0;
}
protected function listenToSignals()
{
if (function_exists('pcntl_signal')) {
declare (ticks = 1);
pcntl_signal(SIGINT, SIG_IGN);
pcntl_signal(SIGTERM, SIG_IGN);
}
}
}
@@ -0,0 +1,250 @@
<?php
namespace Codeception\Command;
use Codeception\Configuration;
use Codeception\Event\DispatcherWrapper;
use Codeception\Event\SuiteEvent;
use Codeception\Event\TestEvent;
use Codeception\Events;
use Codeception\Lib\Generator\Actions;
use Codeception\Lib\ModuleContainer;
use Codeception\Module;
use Codeception\Step;
use Codeception\Stub;
use Codeception\Subscriber\Bootstrap as BootstrapLoader;
use Codeception\Subscriber\Console as ConsolePrinter;
use Codeception\SuiteManager;
use Codeception\Test\Interfaces\ScenarioDriven;
use Codeception\Test\Test;
use Codeception\Util\Maybe;
use PHPUnit\Framework\MockObject\MockObject;
use ReflectionIntersectionType;
use ReflectionMethod;
use ReflectionType;
use ReflectionUnionType;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\EventDispatcher\EventDispatcher;
/**
* Shows step by step execution process for scenario driven tests without actually running them.
*
* * `codecept dry-run acceptance`
* * `codecept dry-run acceptance MyCest`
* * `codecept dry-run acceptance checkout.feature`
* * `codecept dry-run tests/acceptance/MyCest.php`
*
*/
class DryRun extends Command
{
use DispatcherWrapper;
use Shared\Config;
use Shared\Style;
protected function configure()
{
$this->setDefinition(
[
new InputArgument('suite', InputArgument::REQUIRED, 'suite to scan for feature files'),
new InputArgument('test', InputArgument::OPTIONAL, 'tests to be loaded'),
]
);
parent::configure();
}
public function getDescription()
{
return 'Prints step-by-step scenario-driven test or a feature';
}
public function execute(InputInterface $input, OutputInterface $output)
{
$this->addStyles($output);
$suite = $input->getArgument('suite');
$test = $input->getArgument('test');
$config = $this->getGlobalConfig();
ini_set(
'memory_limit',
isset($config['settings']['memory_limit']) ? $config['settings']['memory_limit'] : '1024M'
);
if (! Configuration::isEmpty() && ! $test && strpos($suite, $config['paths']['tests']) === 0) {
list(, $suite, $test) = $this->matchTestFromFilename($suite, $config['paths']['tests']);
}
$settings = $this->getSuiteConfig($suite);
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new ConsolePrinter([
'colors' => (!$input->hasParameterOption('--no-ansi') xor $input->hasParameterOption('ansi')),
'steps' => true,
'verbosity' => OutputInterface::VERBOSITY_VERBOSE,
]));
$dispatcher->addSubscriber(new BootstrapLoader());
$suiteManager = new SuiteManager($dispatcher, $suite, $settings);
$moduleContainer = $suiteManager->getModuleContainer();
foreach (Configuration::modules($settings) as $module) {
$this->mockModule($module, $moduleContainer);
}
$suiteManager->loadTests($test);
$tests = $suiteManager->getSuite()->tests();
$this->dispatch($dispatcher, Events::SUITE_INIT, new SuiteEvent($suiteManager->getSuite(), null, $settings));
$this->dispatch($dispatcher, Events::SUITE_BEFORE, new SuiteEvent($suiteManager->getSuite(), null, $settings));
foreach ($tests as $test) {
if ($test instanceof Test and $test instanceof ScenarioDriven) {
$this->dryRunTest($output, $dispatcher, $test);
}
}
$this->dispatch($dispatcher, Events::SUITE_AFTER, new SuiteEvent($suiteManager->getSuite()));
return 0;
}
protected function matchTestFromFilename($filename, $tests_path)
{
$filename = str_replace(['//', '\/', '\\'], '/', $filename);
$res = preg_match("~^$tests_path/(.*?)/(.*)$~", $filename, $matches);
if (!$res) {
throw new \InvalidArgumentException("Test file can't be matched");
}
return $matches;
}
/**
* @param OutputInterface $output
* @param $dispatcher
* @param $test
*/
protected function dryRunTest(OutputInterface $output, EventDispatcher $dispatcher, Test $test)
{
$this->dispatch($dispatcher, Events::TEST_START, new TestEvent($test));
$this->dispatch($dispatcher, Events::TEST_BEFORE, new TestEvent($test));
try {
$test->test();
} catch (\Exception $e) {
}
$this->dispatch($dispatcher, Events::TEST_AFTER, new TestEvent($test));
$this->dispatch($dispatcher, Events::TEST_END, new TestEvent($test));
if ($test->getMetadata()->isBlocked()) {
$output->writeln('');
if ($skip = $test->getMetadata()->getSkip()) {
$output->writeln("<warning> SKIPPED </warning>" . $skip);
}
if ($incomplete = $test->getMetadata()->getIncomplete()) {
$output->writeln("<warning> INCOMPLETE </warning>" . $incomplete);
}
}
$output->writeln('');
}
/**
* @return Module&MockObject
*/
private function mockModule($moduleName, ModuleContainer $moduleContainer)
{
$module = $moduleContainer->getModule($moduleName);
$class = new \ReflectionClass($module);
$methodResults = [];
foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
if ($method->isConstructor()) {
continue;
}
$methodResults[$method->getName()] = $this->getDefaultResultForMethod($class, $method);
}
$moduleContainer->mock($moduleName, Stub::makeEmpty($module, $methodResults));
}
private function getDefaultResultForMethod(\ReflectionClass $class, ReflectionMethod $method)
{
if (PHP_VERSION_ID < 70000) {
return new Maybe();
}
$returnType = $method->getReturnType();
if ($returnType === null || $returnType->allowsNull()) {
return null;
}
if ($returnType instanceof ReflectionUnionType) {
return $this->getDefaultValueOfUnionType($returnType);
}
if ($returnType instanceof ReflectionIntersectionType) {
return $this->returnDefaultValueForIntersectionType($returnType);
}
if (PHP_VERSION_ID >= 70100 && $returnType->isBuiltin()) {
return $this->getDefaultValueForBuiltinType($returnType);
}
$typeName = Actions::stringifyNamedType($returnType, $class);
return Stub::makeEmpty($typeName);
}
private function getDefaultValueForBuiltinType(ReflectionType $returnType)
{
switch ($returnType->getName()) {
case 'mixed':
case 'void':
return null;
case 'string':
return '';
case 'int':
return 0;
case 'float':
return 0.0;
case 'bool':
return false;
case 'array':
return [];
case 'resource':
return fopen('data://text/plain;base64,', 'r');
default:
throw new \Exception('Unsupported return type ' . $returnType->getName());
}
}
private function getDefaultValueOfUnionType($returnType)
{
$unionTypes = $returnType->getTypes();
foreach ($unionTypes as $type) {
if ($type->isBuiltin()) {
return $this->getDefaultValueForBuiltinType($type);
}
}
return Stub::makeEmpty($unionTypes[0]);
}
private function returnDefaultValueForIntersectionType(ReflectionIntersectionType $returnType)
{
$extends = null;
$implements = [];
foreach ($returnType->getTypes() as $type) {
if (class_exists($type->getName())) {
$extends = $type;
} else {
$implements [] = $type;
}
}
$className = uniqid('anonymous_class_');
$code = "abstract class $className";
if ($extends !== null) {
$code .= " extends \\$extends";
}
if (count($implements) > 0) {
$code .= ' implements ' . implode(', ', $implements);
}
$code .= ' {}';
eval($code);
return Stub::makeEmpty($className);
}
}
@@ -0,0 +1,51 @@
<?php
namespace Codeception\Command;
use Codeception\Lib\Generator\Cept;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @deprecated
*/
class GenerateCept extends Command
{
use Shared\FileSystem;
use Shared\Config;
protected function configure()
{
$this->setDefinition([
new InputArgument('suite', InputArgument::REQUIRED, 'suite to be tested'),
new InputArgument('test', InputArgument::REQUIRED, 'test to be run'),
]);
}
public function getDescription()
{
return 'Generates empty Cept file in suite';
}
public function execute(InputInterface $input, OutputInterface $output)
{
$suite = $input->getArgument('suite');
$filename = $input->getArgument('test');
$config = $this->getSuiteConfig($suite);
$this->createDirectoryFor($config['path'], $filename);
$filename = $this->completeSuffix($filename, 'Cept');
$gen = new Cept($config);
$full_path = rtrim($config['path'], DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename;
$res = $this->createFile($full_path, $gen->produce());
if (!$res) {
$output->writeln("<error>Test $filename already exists</error>");
return 1;
}
$output->writeln("<info>Test was created in $full_path</info>");
return 0;
}
}
@@ -0,0 +1,63 @@
<?php
namespace Codeception\Command;
use Codeception\Lib\Generator\Cest as CestGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Generates Cest (scenario-driven object-oriented test) file:
*
* * `codecept generate:cest suite Login`
* * `codecept g:cest suite subdir/subdir/testnameCest.php`
* * `codecept g:cest suite LoginCest -c path/to/project`
* * `codecept g:cest "App\Login"`
*
*/
class GenerateCest extends Command
{
use Shared\FileSystem;
use Shared\Config;
protected function configure()
{
$this->setDefinition([
new InputArgument('suite', InputArgument::REQUIRED, 'suite where tests will be put'),
new InputArgument('class', InputArgument::REQUIRED, 'test name'),
]);
}
public function getDescription()
{
return 'Generates empty Cest file in suite';
}
public function execute(InputInterface $input, OutputInterface $output)
{
$suite = $input->getArgument('suite');
$class = $input->getArgument('class');
$config = $this->getSuiteConfig($suite);
$className = $this->getShortClassName($class);
$path = $this->createDirectoryFor($config['path'], $class);
$filename = $this->completeSuffix($className, 'Cest');
$filename = $path . $filename;
if (file_exists($filename)) {
$output->writeln("<error>Test $filename already exists</error>");
return 1;
}
$gen = new CestGenerator($class, $config);
$res = $this->createFile($filename, $gen->produce());
if (!$res) {
$output->writeln("<error>Test $filename already exists</error>");
return 1;
}
$output->writeln("<info>Test was created in $filename</info>");
return 0;
}
}
@@ -0,0 +1,60 @@
<?php
namespace Codeception\Command;
use Codeception\Configuration;
use Codeception\Exception\ConfigurationException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Generates empty environment configuration file into envs dir:
*
* * `codecept g:env firefox`
*
* Required to have `envs` path to be specified in `codeception.yml`
*/
class GenerateEnvironment extends Command
{
use Shared\FileSystem;
use Shared\Config;
protected function configure()
{
$this->setDefinition([
new InputArgument('env', InputArgument::REQUIRED, 'Environment name'),
]);
}
public function getDescription()
{
return 'Generates empty environment config';
}
public function execute(InputInterface $input, OutputInterface $output)
{
$conf = $this->getGlobalConfig();
if (!Configuration::envsDir()) {
throw new ConfigurationException(
"Path for environments configuration is not set.\n"
. "Please specify envs path in your `codeception.yml`\n \n"
. "envs: tests/_envs"
);
}
$relativePath = $conf['paths']['envs'];
$env = $input->getArgument('env');
$file = "$env.yml";
$path = $this->createDirectoryFor($relativePath, $file);
$saved = $this->createFile($path . $file, "# `$env` environment config goes here");
if ($saved) {
$output->writeln("<info>$env config was created in $relativePath/$file</info>");
return 0;
} else {
$output->writeln("<error>File $relativePath/$file already exists</error>");
return 1;
}
}
}
@@ -0,0 +1,59 @@
<?php
namespace Codeception\Command;
use Codeception\Lib\Generator\Feature;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Generates Feature file (in Gherkin):
*
* * `codecept generate:feature suite Login`
* * `codecept g:feature suite subdir/subdir/login.feature`
* * `codecept g:feature suite login.feature -c path/to/project`
*
*/
class GenerateFeature extends Command
{
use Shared\FileSystem;
use Shared\Config;
protected function configure()
{
$this->setDefinition([
new InputArgument('suite', InputArgument::REQUIRED, 'suite to be tested'),
new InputArgument('feature', InputArgument::REQUIRED, 'feature to be generated'),
new InputOption('config', 'c', InputOption::VALUE_OPTIONAL, 'Use custom path for config'),
]);
}
public function getDescription()
{
return 'Generates empty feature file in suite';
}
public function execute(InputInterface $input, OutputInterface $output)
{
$suite = $input->getArgument('suite');
$filename = $input->getArgument('feature');
$config = $this->getSuiteConfig($suite);
$this->createDirectoryFor($config['path'], $filename);
$gen = new Feature(basename($filename));
if (!preg_match('~\.feature$~', $filename)) {
$filename .= '.feature';
}
$full_path = rtrim($config['path'], DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename;
$res = $this->createFile($full_path, $gen->produce());
if (!$res) {
$output->writeln("<error>Feature $filename already exists</error>");
return 1;
}
$output->writeln("<info>Feature was created in $full_path</info>");
return 0;
}
}
@@ -0,0 +1,57 @@
<?php
namespace Codeception\Command;
use Codeception\Configuration;
use Codeception\Lib\Generator\Group as GroupGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Creates empty GroupObject - extension which handles all group events.
*
* * `codecept g:group Admin`
*/
class GenerateGroup extends Command
{
use Shared\FileSystem;
use Shared\Config;
protected function configure()
{
$this->setDefinition([
new InputArgument('group', InputArgument::REQUIRED, 'Group class name'),
]);
}
public function getDescription()
{
return 'Generates Group subscriber';
}
public function execute(InputInterface $input, OutputInterface $output)
{
$config = $this->getGlobalConfig();
$group = $input->getArgument('group');
$class = ucfirst($group);
$path = $this->createDirectoryFor(Configuration::supportDir() . 'Group' . DIRECTORY_SEPARATOR, $class);
$filename = $path . $class . '.php';
$gen = new GroupGenerator($config, $group);
$res = $this->createFile($filename, $gen->produce());
if (!$res) {
$output->writeln("<error>Group $filename already exists</error>");
return 1;
}
$output->writeln("<info>Group extension was created in $filename</info>");
$output->writeln(
'To use this group extension, include it to "extensions" option of global Codeception config.'
);
return 0;
}
}
@@ -0,0 +1,52 @@
<?php
namespace Codeception\Command;
use Codeception\Configuration;
use Codeception\Lib\Generator\Helper;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Creates empty Helper class.
*
* * `codecept g:helper MyHelper`
* * `codecept g:helper "My\Helper"`
*
*/
class GenerateHelper extends Command
{
use Shared\FileSystem;
use Shared\Config;
protected function configure()
{
$this->setDefinition([
new InputArgument('name', InputArgument::REQUIRED, 'helper name'),
]);
}
public function getDescription()
{
return 'Generates new helper';
}
public function execute(InputInterface $input, OutputInterface $output)
{
$name = ucfirst($input->getArgument('name'));
$config = $this->getGlobalConfig();
$path = $this->createDirectoryFor(Configuration::supportDir() . 'Helper', $name);
$filename = $path . $this->getShortClassName($name) . '.php';
$res = $this->createFile($filename, (new Helper($name, $config['namespace']))->produce());
if ($res) {
$output->writeln("<info>Helper $filename created</info>");
return 0;
} else {
$output->writeln("<error>Error creating helper $filename</error>");
return 1;
}
}
}
@@ -0,0 +1,76 @@
<?php
namespace Codeception\Command;
use Codeception\Configuration;
use Codeception\Lib\Generator\PageObject as PageObjectGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Generates PageObject. Can be generated either globally, or just for one suite.
* If PageObject is generated globally it will act as UIMap, without any logic in it.
*
* * `codecept g:page Login`
* * `codecept g:page Registration`
* * `codecept g:page acceptance Login`
*/
class GeneratePageObject extends Command
{
use Shared\FileSystem;
use Shared\Config;
protected function configure()
{
$this->setDefinition([
new InputArgument('suite', InputArgument::REQUIRED, 'Either suite name or page object name)'),
new InputArgument('page', InputArgument::OPTIONAL, 'Page name of pageobject to represent'),
]);
parent::configure();
}
public function getDescription()
{
return 'Generates empty PageObject class';
}
public function execute(InputInterface $input, OutputInterface $output)
{
$suite = $input->getArgument('suite');
$class = $input->getArgument('page');
if (!$class) {
$class = $suite;
$suite = '';
}
$conf = $suite
? $this->getSuiteConfig($suite)
: $this->getGlobalConfig();
if ($suite) {
$suite = DIRECTORY_SEPARATOR . ucfirst($suite);
}
$path = $this->createDirectoryFor(Configuration::supportDir() . 'Page' . $suite, $class);
$filename = $path . $this->getShortClassName($class) . '.php';
$output->writeln($filename);
$gen = new PageObjectGenerator($conf, ucfirst($suite) . '\\' . $class);
$res = $this->createFile($filename, $gen->produce());
if (!$res) {
$output->writeln("<error>PageObject $filename already exists</error>");
return 1;
}
$output->writeln("<info>PageObject was created in $filename</info>");
return 0;
}
protected function pathToPageObject($class, $suite)
{
}
}
@@ -0,0 +1,143 @@
<?php
namespace Codeception\Command;
use Codeception\Configuration;
use Codeception\Exception\ConfigurationException;
use Codeception\Test\Cest;
use Codeception\Test\Interfaces\ScenarioDriven;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\EventDispatcher\EventDispatcher;
/**
* Generates user-friendly text scenarios from scenario-driven tests (Cest).
*
* * `codecept g:scenarios acceptance` - for all acceptance tests
* * `codecept g:scenarios acceptance --format html` - in html format
* * `codecept g:scenarios acceptance --path doc` - generate scenarios to `doc` dir
*/
class GenerateScenarios extends Command
{
use Shared\FileSystem;
use Shared\Config;
protected function configure()
{
$this->setDefinition([
new InputArgument('suite', InputArgument::REQUIRED, 'suite from which texts should be generated'),
new InputOption('path', 'p', InputOption::VALUE_REQUIRED, 'Use specified path as destination instead of default'),
new InputOption('single-file', '', InputOption::VALUE_NONE, 'Render all scenarios to only one file'),
new InputOption('format', 'f', InputOption::VALUE_REQUIRED, 'Specify output format: html or text (default)', 'text'),
]);
parent::configure();
}
public function getDescription()
{
return 'Generates text representation for all scenarios';
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$suite = $input->getArgument('suite');
$suiteConf = $this->getSuiteConfig($suite);
$path = $input->getOption('path')
? $input->getOption('path')
: Configuration::dataDir() . 'scenarios';
$format = $input->getOption('format');
@mkdir($path);
if (!is_writable($path)) {
throw new ConfigurationException(
"Path $path is not writable. Please, set valid permissions for folder to store scenarios."
);
}
$path = $path . DIRECTORY_SEPARATOR . $suite;
if (!$input->getOption('single-file')) {
@mkdir($path);
}
$suiteManager = new \Codeception\SuiteManager(new EventDispatcher(), $suite, $suiteConf);
if ($suiteConf['bootstrap']) {
if (file_exists($suiteConf['path'] . $suiteConf['bootstrap'])) {
require_once $suiteConf['path'] . $suiteConf['bootstrap'];
}
}
$tests = $this->getTests($suiteManager);
$scenarios = "";
foreach ($tests as $test) {
if (!($test instanceof ScenarioDriven)) {
continue;
}
$feature = $test->getScenarioText($format);
$name = $this->underscore(basename($test->getFileName(), '.php'));
// create separate file for each test in Cest
if ($test instanceof Cest && !$input->getOption('single-file')) {
$name .= '.' . $this->underscore($test->getTestMethod());
}
if ($input->getOption('single-file')) {
$scenarios .= $feature;
$output->writeln("* $name rendered");
} else {
$feature = $this->decorate($feature, $format);
$this->createFile($path . DIRECTORY_SEPARATOR . $name . $this->formatExtension($format), $feature, true);
$output->writeln("* $name generated");
}
}
if ($input->getOption('single-file')) {
$this->createFile($path . $this->formatExtension($format), $this->decorate($scenarios, $format), true);
}
return 0;
}
protected function decorate($text, $format)
{
switch ($format) {
case 'text':
return $text;
case 'html':
return "<html><body>$text</body></html>";
}
}
protected function getTests($suiteManager)
{
$suiteManager->loadTests();
return $suiteManager->getSuite()->tests();
}
protected function formatExtension($format)
{
switch ($format) {
case 'text':
return '.txt';
case 'html':
return '.html';
}
}
private function underscore($name)
{
$name = preg_replace('/([A-Z]+)([A-Z][a-z])/', '\\1_\\2', $name);
$name = preg_replace('/([a-z\d])([A-Z])/', '\\1_\\2', $name);
$name = str_replace(['/', '\\'], ['.', '.'], $name);
$name = preg_replace('/_Cept$/', '', $name);
$name = preg_replace('/_Cest$/', '', $name);
return $name;
}
}
@@ -0,0 +1,73 @@
<?php
namespace Codeception\Command;
use Codeception\Configuration;
use Codeception\Lib\Generator\Snapshot as SnapshotGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Generates Snapshot.
* Snapshot can be used to test dynamical data.
* If suite name is provided, an actor class will be included into placeholder
*
* * `codecept g:snapshot UserEmails`
* * `codecept g:snapshot Products`
* * `codecept g:snapshot acceptance UserEmails`
*/
class GenerateSnapshot extends Command
{
use Shared\FileSystem;
use Shared\Config;
protected function configure()
{
$this->setDefinition([
new InputArgument('suite', InputArgument::REQUIRED, 'Suite name or snapshot name)'),
new InputArgument('snapshot', InputArgument::OPTIONAL, 'Name of snapshot'),
]);
parent::configure();
}
public function getDescription()
{
return 'Generates empty Snapshot class';
}
public function execute(InputInterface $input, OutputInterface $output)
{
$suite = $input->getArgument('suite');
$class = $input->getArgument('snapshot');
if (!$class) {
$class = $suite;
$suite = null;
}
$conf = $suite
? $this->getSuiteConfig($suite)
: $this->getGlobalConfig();
if ($suite) {
$suite = DIRECTORY_SEPARATOR . ucfirst($suite);
}
$path = $this->createDirectoryFor(Configuration::supportDir() . 'Snapshot' . $suite, $class);
$filename = $path . $this->getShortClassName($class) . '.php';
$output->writeln($filename);
$gen = new SnapshotGenerator($conf, ucfirst($suite) . '\\' . $class);
$res = $this->createFile($filename, $gen->produce());
if (!$res) {
$output->writeln("<error>Snapshot $filename already exists</error>");
return 1;
}
$output->writeln("<info>Snapshot was created in $filename</info>");
return 0;
}
}
@@ -0,0 +1,76 @@
<?php
namespace Codeception\Command;
use Codeception\Configuration;
use Codeception\Lib\Generator\StepObject as StepObjectGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
/**
* Generates StepObject class. You will be asked for steps you want to implement.
*
* * `codecept g:stepobject acceptance AdminSteps`
* * `codecept g:stepobject acceptance UserSteps --silent` - skip action questions
*
*/
class GenerateStepObject extends Command
{
use Shared\FileSystem;
use Shared\Config;
protected function configure()
{
$this->setDefinition([
new InputArgument('suite', InputArgument::REQUIRED, 'Suite for StepObject'),
new InputArgument('step', InputArgument::REQUIRED, 'StepObject name'),
new InputOption('silent', '', InputOption::VALUE_NONE, 'skip verification question'),
]);
}
public function getDescription()
{
return 'Generates empty StepObject class';
}
public function execute(InputInterface $input, OutputInterface $output)
{
$suite = $input->getArgument('suite');
$step = $input->getArgument('step');
$config = $this->getSuiteConfig($suite);
$class = $this->getShortClassName($step);
$path = $this->createDirectoryFor(Configuration::supportDir() . 'Step' . DIRECTORY_SEPARATOR . ucfirst($suite), $step);
$dialog = $this->getHelperSet()->get('question');
$filename = $path . $class . '.php';
$helper = $this->getHelper('question');
$question = new Question("Add action to StepObject class (ENTER to exit): ");
$gen = new StepObjectGenerator($config, ucfirst($suite) . '\\' . $step);
if (!$input->getOption('silent')) {
do {
$question = new Question('Add action to StepObject class (ENTER to exit): ', null);
$action = $dialog->ask($input, $output, $question);
if ($action) {
$gen->createAction($action);
}
} while ($action);
}
$res = $this->createFile($filename, $gen->produce());
if (!$res) {
$output->writeln("<error>StepObject $filename already exists</error>");
return 1;
}
$output->writeln("<info>StepObject was created in $filename</info>");
return 0;
}
}
@@ -0,0 +1,135 @@
<?php
namespace Codeception\Command;
use Codeception\Configuration;
use Codeception\Lib\Generator\Helper;
use Codeception\Util\Template;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Codeception\Lib\Generator\Actor as ActorGenerator;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Yaml\Yaml;
/**
* Create new test suite. Requires suite name and actor name
*
* * ``
* * `codecept g:suite api` -> api + ApiTester
* * `codecept g:suite integration Code` -> integration + CodeTester
* * `codecept g:suite frontend Front` -> frontend + FrontTester
*
*/
class GenerateSuite extends Command
{
use Shared\FileSystem;
use Shared\Config;
use Shared\Style;
protected function configure()
{
$this->setDefinition([
new InputArgument('suite', InputArgument::REQUIRED, 'suite to be generated'),
new InputArgument('actor', InputArgument::OPTIONAL, 'name of new actor class'),
]);
}
public function getDescription()
{
return 'Generates new test suite';
}
public function execute(InputInterface $input, OutputInterface $output)
{
$this->addStyles($output);
$suite = $input->getArgument('suite');
$actor = $input->getArgument('actor');
if ($this->containsInvalidCharacters($suite)) {
$output->writeln("<error>Suite name '$suite' contains invalid characters. ([A-Za-z0-9_]).</error>");
return 1;
}
$config = $this->getGlobalConfig();
if (!$actor) {
$actor = ucfirst($suite) . $config['actor_suffix'];
}
$dir = Configuration::testsDir();
if (file_exists($dir . $suite . '.suite.yml')) {
throw new \Exception("Suite configuration file '$suite.suite.yml' already exists.");
}
$this->createDirectoryFor($dir . $suite);
if ($config['settings']['bootstrap']) {
// generate bootstrap file
$this->createFile(
$dir . $suite . DIRECTORY_SEPARATOR . $config['settings']['bootstrap'],
"<?php\n",
true
);
}
$helperName = ucfirst($suite);
$file = $this->createDirectoryFor(
Configuration::supportDir() . "Helper",
"$helperName.php"
) . "$helperName.php";
$gen = new Helper($helperName, $config['namespace']);
// generate helper
$this->createFile(
$file,
$gen->produce()
);
$output->writeln("Helper <info>" . $gen->getHelperName() . "</info> was created in $file");
$yamlSuiteConfigTemplate = <<<EOF
actor: {{actor}}
modules:
enabled:
- {{helper}}
EOF;
$this->createFile(
$dir . $suite . '.suite.yml',
$yamlSuiteConfig = (new Template($yamlSuiteConfigTemplate))
->place('actor', $actor)
->place('helper', $gen->getHelperName())
->produce()
);
Configuration::append(Yaml::parse($yamlSuiteConfig));
$actorGenerator = new ActorGenerator(Configuration::config());
$content = $actorGenerator->produce();
$file = $this->createDirectoryFor(
Configuration::supportDir(),
$actor
) . $this->getShortClassName($actor);
$file .= '.php';
$this->createFile($file, $content);
$output->writeln("Actor <info>" . $actor . "</info> was created in $file");
$output->writeln("Suite config <info>$suite.suite.yml</info> was created.");
$output->writeln(' ');
$output->writeln("Next steps:");
$output->writeln("1. Edit <bold>$suite.suite.yml</bold> to enable modules for this suite");
$output->writeln("2. Create first test with <bold>generate:cest testName</bold> ( or test|cept) command");
$output->writeln("3. Run tests of this suite with <bold>codecept run $suite</bold> command");
$output->writeln("<info>Suite $suite generated</info>");
return 0;
}
private function containsInvalidCharacters($suite)
{
return preg_match('#[^A-Za-z0-9_]#', $suite) ? true : false;
}
}
@@ -0,0 +1,61 @@
<?php
namespace Codeception\Command;
use Codeception\Lib\Generator\Test as TestGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Generates skeleton for Unit Test that extends `Codeception\TestCase\Test`.
*
* * `codecept g:test unit User`
* * `codecept g:test unit "App\User"`
*/
class GenerateTest extends Command
{
use Shared\FileSystem;
use Shared\Config;
protected function configure()
{
$this->setDefinition(
[
new InputArgument('suite', InputArgument::REQUIRED, 'suite where tests will be put'),
new InputArgument('class', InputArgument::REQUIRED, 'class name'),
]
);
parent::configure();
}
public function getDescription()
{
return 'Generates empty unit test file in suite';
}
public function execute(InputInterface $input, OutputInterface $output)
{
$suite = $input->getArgument('suite');
$class = $input->getArgument('class');
$config = $this->getSuiteConfig($suite);
$className = $this->getShortClassName($class);
$path = $this->createDirectoryFor($config['path'], $class);
$filename = $this->completeSuffix($className, 'Test');
$filename = $path . $filename;
$gen = new TestGenerator($config, $class);
$res = $this->createFile($filename, $gen->produce());
if (!$res) {
$output->writeln("<error>Test $filename already exists</error>");
return 1;
}
$output->writeln("<info>Test was created in $filename</info>");
return 0;
}
}
@@ -0,0 +1,73 @@
<?php
namespace Codeception\Command;
use Codeception\Lib\Generator\GherkinSnippets as SnippetsGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Generates code snippets for matched feature files in a suite.
* Code snippets are expected to be implemented in Actor or PageObjects
*
* Usage:
*
* * `codecept gherkin:snippets acceptance` - snippets from all feature of acceptance tests
* * `codecept gherkin:snippets acceptance/feature/users` - snippets from `feature/users` dir of acceptance tests
* * `codecept gherkin:snippets acceptance user_account.feature` - snippets from a single feature file
* * `codecept gherkin:snippets acceptance/feature/users/user_accout.feature` - snippets from feature file in a dir
*/
class GherkinSnippets extends Command
{
use Shared\Config;
use Shared\Style;
protected function configure()
{
$this->setDefinition(
[
new InputArgument('suite', InputArgument::REQUIRED, 'suite to scan for feature files'),
new InputArgument('test', InputArgument::OPTIONAL, 'test to be scanned'),
new InputOption('config', 'c', InputOption::VALUE_OPTIONAL, 'Use custom path for config'),
]
);
parent::configure();
}
public function getDescription()
{
return 'Fetches empty steps from feature files of suite and prints code snippets for them';
}
public function execute(InputInterface $input, OutputInterface $output)
{
$this->addStyles($output);
$suite = $input->getArgument('suite');
$test = $input->getArgument('test');
$config = $this->getSuiteConfig($suite);
$generator = new SnippetsGenerator($config, $test);
$snippets = $generator->getSnippets();
$features = $generator->getFeatures();
if (empty($snippets)) {
$output->writeln("<notice> All Gherkin steps are defined. Exiting... </notice>");
return 0;
}
$output->writeln("<comment> Snippets found in: </comment>");
foreach ($features as $feature) {
$output->writeln("<info> - {$feature} </info>");
}
$output->writeln("<comment> Generated Snippets: </comment>");
$output->writeln("<info> ----------------------------------------- </info>");
foreach ($snippets as $snippet) {
$output->writeln($snippet);
}
$output->writeln("<info> ----------------------------------------- </info>");
$output->writeln(sprintf(' <bold>%d</bold> snippets proposed', count($snippets)));
$output->writeln("<notice> Copy generated snippets to {$config['actor']} or a specific Gherkin context </notice>");
return 0;
}
}
@@ -0,0 +1,72 @@
<?php
namespace Codeception\Command;
use Codeception\Test\Loader\Gherkin;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Prints all steps from all Gherkin contexts for a specific suite
*
* ```
* codecept gherkin:steps acceptance
* ```
*
*/
class GherkinSteps extends Command
{
use Shared\Config;
use Shared\Style;
protected function configure()
{
$this->setDefinition(
[
new InputArgument('suite', InputArgument::REQUIRED, 'suite to scan for feature files'),
new InputOption('config', 'c', InputOption::VALUE_OPTIONAL, 'Use custom path for config'),
]
);
parent::configure();
}
public function getDescription()
{
return 'Prints all defined feature steps';
}
public function execute(InputInterface $input, OutputInterface $output)
{
$this->addStyles($output);
$suite = $input->getArgument('suite');
$config = $this->getSuiteConfig($suite);
$config['describe_steps'] = true;
$loader = new Gherkin($config);
$steps = $loader->getSteps();
foreach ($steps as $name => $context) {
/** @var $table Table **/
$table = new Table($output);
$table->setHeaders(['Step', 'Implementation']);
$output->writeln("Steps from <bold>$name</bold> context:");
foreach ($context as $step => $callable) {
if (count($callable) < 2) {
continue;
}
$method = $callable[0] . '::' . $callable[1];
$table->addRow([$step, $method]);
}
$table->render();
}
if (!isset($table)) {
$output->writeln("No steps are defined, start creating them by running <bold>gherkin:snippets</bold>");
}
return 0;
}
}
@@ -0,0 +1,56 @@
<?php
namespace Codeception\Command;
use Codeception\InitTemplate;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class Init extends Command
{
protected function configure()
{
$this->setDefinition(
[
new InputArgument('template', InputArgument::REQUIRED, 'Init template for the setup'),
new InputOption('path', null, InputOption::VALUE_REQUIRED, 'Change current directory', null),
new InputOption('namespace', null, InputOption::VALUE_OPTIONAL, 'Namespace to add for actor classes and helpers\'', null),
]
);
}
public function getDescription()
{
return "Creates test suites by a template";
}
public function execute(InputInterface $input, OutputInterface $output)
{
$template = $input->getArgument('template');
if (class_exists($template)) {
$className = $template;
} else {
$className = 'Codeception\Template\\' . ucfirst($template);
if (!class_exists($className)) {
throw new \Exception("Template from a $className can't be loaded; Init can't be executed");
}
}
$initProcess = new $className($input, $output);
if (!$initProcess instanceof InitTemplate) {
throw new \Exception("$className is not a valid template");
}
if ($input->getOption('path')) {
$initProcess->initDir($input->getOption('path'));
}
$initProcess->setup();
return 0;
}
}
@@ -0,0 +1,758 @@
<?php
namespace Codeception\Command;
use Codeception\Codecept;
use Codeception\Configuration;
use Codeception\Exception\ParseException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Executes tests.
*
* Usage:
*
* * `codecept run acceptance`: run all acceptance tests
* * `codecept run tests/acceptance/MyCest.php`: run only MyCest
* * `codecept run acceptance MyCest`: same as above
* * `codecept run acceptance MyCest:myTestInIt`: run one test from a Cest
* * `codecept run acceptance checkout.feature`: run feature-file
* * `codecept run acceptance -g slow`: run tests from *slow* group
* * `codecept run unit,functional`: run only unit and functional suites
*
* Verbosity modes:
*
* * `codecept run -v`:
* * `codecept run --steps`: print step-by-step execution
* * `codecept run -vv`: print steps and debug information
* * `codecept run --debug`: alias for `-vv`
* * `codecept run -vvv`: print Codeception-internal debug information
*
* Load config:
*
* * `codecept run -c path/to/another/config`: from another dir
* * `codecept run -c another_config.yml`: from another config file
*
* Override config values:
*
* * `codecept run -o "settings: shuffle: true"`: enable shuffle
* * `codecept run -o "settings: lint: false"`: disable linting
* * `codecept run -o "reporters: report: \Custom\Reporter" --report`: use custom reporter
*
* Run with specific extension
*
* * `codecept run --ext Recorder` run with Recorder extension enabled
* * `codecept run --ext DotReporter` run with DotReporter printer
* * `codecept run --ext "My\Custom\Extension"` run with an extension loaded by class name
*
* Full reference:
* ```
* Arguments:
* suite suite to be tested
* test test to be run
*
* Options:
* -o, --override=OVERRIDE Override config values (multiple values allowed)
* --config (-c) Use custom path for config
* --report Show output in compact style
* --html Generate html with results (default: "report.html")
* --xml Generate JUnit XML Log (default: "report.xml")
* --phpunit-xml Generate PhpUnit XML Log (default: "phpunit-report.xml")
* --no-redirect Do not redirect to Composer-installed version in vendor/codeception
* --tap Generate Tap Log (default: "report.tap.log")
* --json Generate Json Log (default: "report.json")
* --colors Use colors in output
* --no-colors Force no colors in output (useful to override config file)
* --silent Only outputs suite names and final results. Almost the same as `--quiet`
* --steps Show steps in output
* --debug (-d) Alias for `-vv`
* --bootstrap Execute bootstrap script before the test
* --coverage Run with code coverage (default: "coverage.serialized")
* --coverage-html Generate CodeCoverage HTML report in path (default: "coverage")
* --coverage-xml Generate CodeCoverage XML report in file (default: "coverage.xml")
* --coverage-text Generate CodeCoverage text report in file (default: "coverage.txt")
* --coverage-phpunit Generate CodeCoverage PHPUnit report in file (default: "coverage-phpunit")
* --coverage-cobertura Generate CodeCoverage Cobertura report in file (default: "coverage-cobertura")
* --no-exit Don't finish with exit code
* --group (-g) Groups of tests to be executed (multiple values allowed)
* --skip (-s) Skip selected suites (multiple values allowed)
* --skip-group (-x) Skip selected groups (multiple values allowed)
* --env Run tests in selected environments. (multiple values allowed, environments can be merged with ',')
* --fail-fast (-f) Stop after first failure
* --no-rebuild Do not rebuild actor classes on start
* --help (-h) Display this help message.
* --quiet (-q) Do not output any message. Almost the same as `--silent`
* --verbose (-v|vv|vvv) Increase the verbosity of messages: `v` for normal output, `vv` for steps and debug, `vvv` for Codeception-internal debug
* --version (-V) Display this application version.
* --ansi Force ANSI output.
* --no-ansi Disable ANSI output.
* --no-interaction (-n) Do not ask any interactive question.
* --seed Use the given seed for shuffling tests
* ```
*
*/
class Run extends Command
{
use Shared\Config;
/**
* @var Codecept
*/
protected $codecept;
/**
* @var integer of executed suites
*/
protected $executed = 0;
/**
* @var array of options (command run)
*/
protected $options = [];
/**
* @var OutputInterface
*/
protected $output;
/**
* Sets Run arguments
* @throws \Symfony\Component\Console\Exception\InvalidArgumentException
*/
protected function configure()
{
$this->setDefinition([
new InputArgument('suite', InputArgument::OPTIONAL, 'suite to be tested'),
new InputArgument('test', InputArgument::OPTIONAL, 'test to be run'),
new InputOption('override', 'o', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Override config values'),
new InputOption('ext', 'e', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Run with extension enabled'),
new InputOption('report', '', InputOption::VALUE_NONE, 'Show output in compact style'),
new InputOption('html', '', InputOption::VALUE_OPTIONAL, 'Generate html with results', 'report.html'),
new InputOption('xml', '', InputOption::VALUE_OPTIONAL, 'Generate JUnit XML Log', 'report.xml'),
new InputOption('phpunit-xml', '', InputOption::VALUE_OPTIONAL, 'Generate PhpUnit XML Log', 'phpunit-report.xml'),
new InputOption('tap', '', InputOption::VALUE_OPTIONAL, 'Generate Tap Log', 'report.tap.log'),
new InputOption('json', '', InputOption::VALUE_OPTIONAL, 'Generate Json Log', 'report.json'),
new InputOption('colors', '', InputOption::VALUE_NONE, 'Use colors in output'),
new InputOption(
'no-colors',
'',
InputOption::VALUE_NONE,
'Force no colors in output (useful to override config file)'
),
new InputOption('silent', '', InputOption::VALUE_NONE, 'Only outputs suite names and final results'),
new InputOption('steps', '', InputOption::VALUE_NONE, 'Show steps in output'),
new InputOption('debug', 'd', InputOption::VALUE_NONE, 'Show debug and scenario output'),
new InputOption('bootstrap', '', InputOption::VALUE_OPTIONAL, 'Execute custom PHP script before running tests. Path can be absolute or relative to current working directory', false),
new InputOption('no-redirect', '', InputOption::VALUE_NONE, 'Do not redirect to Composer-installed version in vendor/codeception'),
new InputOption(
'coverage',
'',
InputOption::VALUE_OPTIONAL,
'Run with code coverage'
),
new InputOption(
'coverage-html',
'',
InputOption::VALUE_OPTIONAL,
'Generate CodeCoverage HTML report in path'
),
new InputOption(
'coverage-xml',
'',
InputOption::VALUE_OPTIONAL,
'Generate CodeCoverage XML report in file'
),
new InputOption(
'coverage-text',
'',
InputOption::VALUE_OPTIONAL,
'Generate CodeCoverage text report in file'
),
new InputOption(
'coverage-crap4j',
'',
InputOption::VALUE_OPTIONAL,
'Generate CodeCoverage report in Crap4J XML format'
),
new InputOption(
'coverage-cobertura',
'',
InputOption::VALUE_OPTIONAL,
'Generate CodeCoverage report in Cobertura XML format'
),
new InputOption(
'coverage-phpunit',
'',
InputOption::VALUE_OPTIONAL,
'Generate CodeCoverage PHPUnit report in path'
),
new InputOption('no-exit', '', InputOption::VALUE_NONE, 'Don\'t finish with exit code'),
new InputOption(
'group',
'g',
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
'Groups of tests to be executed'
),
new InputOption(
'skip',
's',
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
'Skip selected suites'
),
new InputOption(
'skip-group',
'x',
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
'Skip selected groups'
),
new InputOption(
'env',
'',
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
'Run tests in selected environments.'
),
new InputOption('fail-fast', 'f', InputOption::VALUE_NONE, 'Stop after first failure'),
new InputOption('no-rebuild', '', InputOption::VALUE_NONE, 'Do not rebuild actor classes on start'),
new InputOption(
'seed',
'',
InputOption::VALUE_REQUIRED,
'Define random seed for shuffle setting'
),
new InputOption('no-artifacts', '', InputOption::VALUE_NONE, 'Don\'t report about artifacts'),
]);
parent::configure();
}
public function getDescription()
{
return 'Runs the test suites';
}
/**
* Executes Run
*
* @param \Symfony\Component\Console\Input\InputInterface $input
* @param \Symfony\Component\Console\Output\OutputInterface $output
* @return int|null|void
* @throws \RuntimeException
*/
public function execute(InputInterface $input, OutputInterface $output)
{
$this->ensurePhpExtIsAvailable('CURL');
$this->ensurePhpExtIsAvailable('mbstring');
$this->options = $input->getOptions();
$this->output = $output;
if ($this->options['bootstrap']) {
Configuration::loadBootstrap($this->options['bootstrap'], getcwd());
}
$config = $this->getGlobalConfig();
$config = $this->addRuntimeOptionsToCurrentConfig($config);
if (!$this->options['colors']) {
$this->options['colors'] = $config['settings']['colors'];
}
if (!$this->options['silent']) {
$this->output->writeln(
Codecept::versionString() . " https://helpukrainewin.org\nPowered by "
. \PHPUnit\Runner\Version::getVersionString()
);
if ($this->options['seed']) {
$this->output->writeln(
"Running with seed: <info>" . $this->options['seed'] . "</info>\n"
);
}
}
if ($this->options['debug']) {
$this->output->setVerbosity(OutputInterface::VERBOSITY_VERY_VERBOSE);
}
$userOptions = array_intersect_key($this->options, array_flip($this->passedOptionKeys($input)));
$userOptions = array_merge(
$userOptions,
$this->booleanOptions($input, [
'xml' => 'report.xml',
'phpunit-xml' => 'phpunit-report.xml',
'html' => 'report.html',
'json' => 'report.json',
'tap' => 'report.tap.log',
'coverage' => 'coverage.serialized',
'coverage-xml' => 'coverage.xml',
'coverage-html' => 'coverage',
'coverage-text' => 'coverage.txt',
'coverage-crap4j' => 'crap4j.xml',
'coverage-cobertura' => 'cobertura.xml',
'coverage-phpunit' => 'coverage-phpunit'])
);
$userOptions['verbosity'] = $this->output->getVerbosity();
$userOptions['interactive'] = !$input->hasParameterOption(['--no-interaction', '-n']);
$userOptions['ansi'] = (!$input->hasParameterOption('--no-ansi') xor $input->hasParameterOption('ansi'));
if (!$this->options['seed']) {
$userOptions['seed'] = rand();
} else {
$userOptions['seed'] = intval($this->options['seed']);
}
if ($this->options['no-colors'] || !$userOptions['ansi']) {
$userOptions['colors'] = false;
}
if ($this->options['group']) {
$userOptions['groups'] = $this->options['group'];
}
if ($this->options['skip-group']) {
$userOptions['excludeGroups'] = $this->options['skip-group'];
}
if ($this->options['report']) {
$userOptions['silent'] = true;
}
if ($this->options['coverage-xml'] or $this->options['coverage-html'] or $this->options['coverage-text'] or $this->options['coverage-crap4j'] or $this->options['coverage-phpunit']) {
$this->options['coverage'] = true;
}
if (!$userOptions['ansi'] && $input->getOption('colors')) {
$userOptions['colors'] = true; // turn on colors even in non-ansi mode if strictly passed
}
$suite = $input->getArgument('suite');
$test = $input->getArgument('test');
if ($this->options['group']) {
$this->output->writeln(sprintf("[Groups] <info>%s</info> ", implode(', ', $this->options['group'])));
}
if ($input->getArgument('test')) {
$this->options['steps'] = true;
}
if (!$test) {
// Check if suite is given and is in an included path
if (!empty($suite) && !empty($config['include'])) {
$isIncludeTest = false;
// Remember original projectDir
$projectDir = Configuration::projectDir();
foreach ($config['include'] as $include) {
// Find if the suite begins with an include path
if (strpos($suite, $include) === 0) {
// Use include config
$config = Configuration::config($projectDir.$include);
$config = $this->addRuntimeOptionsToCurrentConfig($config);
if (!isset($config['paths']['tests'])) {
throw new \RuntimeException(
sprintf("Included '%s' has no tests path configured", $include)
);
}
$testsPath = $include . DIRECTORY_SEPARATOR. $config['paths']['tests'];
try {
list(, $suite, $test) = $this->matchTestFromFilename($suite, $testsPath);
$isIncludeTest = true;
} catch (\InvalidArgumentException $e) {
// Incorrect include match, continue trying to find one
continue;
}
} else {
$result = $this->matchSingleTest($suite, $config);
if ($result) {
list(, $suite, $test) = $result;
}
}
}
// Restore main config
if (!$isIncludeTest) {
$config = $this->addRuntimeOptionsToCurrentConfig(
Configuration::config($projectDir)
);
}
} elseif (!empty($suite)) {
$result = $this->matchSingleTest($suite, $config);
if ($result) {
list(, $suite, $test) = $result;
}
}
}
if ($test) {
$userOptions['filter'] = $this->matchFilteredTestName($test);
} elseif (
$suite
&& ! $this->isWildcardSuiteName($suite)
&& ! $this->isSuiteInMultiApplication($suite)
) {
$userOptions['filter'] = $this->matchFilteredTestName($suite);
}
if (!$this->options['silent'] && $config['settings']['shuffle']) {
$this->output->writeln(
"[Seed] <info>" . $userOptions['seed'] . "</info>"
);
}
$this->codecept = new Codecept($userOptions);
if ($suite and $test) {
$this->codecept->run($suite, $test, $config);
}
// Run all tests of given suite or all suites
if (!$test) {
$didPassCliSuite = !empty($suite);
$rawSuites = $didPassCliSuite ? explode(',', $suite) : Configuration::suites();
/** @var string[] $mainAppSuites */
$mainAppSuites = [];
/** @var array<string,string> $appSpecificSuites */
$appSpecificSuites = [];
/** @var string[] $wildcardSuites */
$wildcardSuites = [];
foreach ($rawSuites as $rawSuite) {
if($this->isWildcardSuiteName($rawSuite)){
$wildcardSuites[] = explode('*::', $rawSuite)[1];
continue;
}
if($this->isSuiteInMultiApplication($rawSuite)){
$appAndSuite = explode('::', $rawSuite);
$appSpecificSuites[$appAndSuite[0]][] = $appAndSuite[1];
continue;
}
$mainAppSuites[] = $rawSuite;
}
if([] !== $mainAppSuites) {
$this->executed = $this->runSuites($mainAppSuites, $this->options['skip']);
}
if(!empty($wildcardSuites) && ! empty($appSpecificSuites)) {
$this->output->writeLn('<error>Wildcard options can not be combined with specific suites of included apps.</error>');
return 2;
}
if(
!empty($config['include'])
&& (!$didPassCliSuite || !empty($wildcardSuites) || !empty($appSpecificSuites))
) {
$currentDir = Configuration::projectDir();
$includedApps = $config['include'];
if(!empty($appSpecificSuites)){
$includedApps = array_intersect($includedApps, array_keys($appSpecificSuites));
}
$this->runIncludedSuites(
$includedApps,
$currentDir,
$appSpecificSuites,
$wildcardSuites
);
}
if ($this->executed === 0) {
throw new \RuntimeException(
sprintf("Suite '%s' could not be found", implode(', ', $rawSuites))
);
}
}
$this->codecept->printResult();
if (!$input->getOption('no-exit')) {
if (!$this->codecept->getResult()->wasSuccessful()) {
exit(1);
}
}
return 0;
}
protected function matchSingleTest($suite, $config)
{
// Workaround when codeception.yml is inside tests directory and tests path is set to "."
// @see https://github.com/Codeception/Codeception/issues/4432
if (isset($config['paths']['tests']) && $config['paths']['tests'] === '.' && !preg_match('~^\.[/\\\]~', $suite)) {
$suite = './' . $suite;
}
// running a single test when suite has a configured path
if (isset($config['suites'])) {
foreach ($config['suites'] as $s => $suiteConfig) {
if (!isset($suiteConfig['path'])) {
continue;
}
$testsPath = $config['paths']['tests'] . DIRECTORY_SEPARATOR . $suiteConfig['path'];
if ($suiteConfig['path'] === '.') {
$testsPath = $config['paths']['tests'];
}
if (preg_match("~^$testsPath/(.*?)$~", $suite, $matches)) {
$matches[2] = $matches[1];
$matches[1] = $s;
return $matches;
}
}
}
if (! Configuration::isEmpty()) {
// Run single test without included tests
if (strpos($suite, $config['paths']['tests']) === 0) {
return $this->matchTestFromFilename($suite, $config['paths']['tests']);
}
// Run single test from working directory
$realTestDir = realpath(Configuration::testsDir());
$cwd = getcwd();
if (strpos($realTestDir, $cwd) === 0) {
$file = $suite;
if (strpos($file, ':') !== false) {
list($file) = explode(':', $suite, -1);
}
$realPath = $cwd . DIRECTORY_SEPARATOR . $file;
if (file_exists($realPath) && strpos($realPath, $realTestDir) === 0) {
//only match test if file is in tests directory
return $this->matchTestFromFilename(
$cwd . DIRECTORY_SEPARATOR . $suite,
$realTestDir
);
}
}
}
}
/**
* Runs included suites recursively
*
* @param array $suites
* @param string $parent_dir
* @param array<string,string[]> $filterAppSuites An array keyed by included app name where values are suite names to run.
* @param string[] $filterSuitesByWildcard A list of suite names (applies to all included apps)
*/
protected function runIncludedSuites($suites, $parent_dir, $filterAppSuites = [], $filterSuitesByWildcard = [])
{
$defaultConfig = Configuration::config();
$absolutePath = \Codeception\Configuration::projectDir();
foreach ($suites as $relativePath) {
$current_dir = rtrim($parent_dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $relativePath;
$config = Configuration::config($current_dir);
if ( !empty($defaultConfig['groups'])) {
$groups = array_map(function($g) use ($absolutePath) {
return $absolutePath . $g;
}, $defaultConfig['groups']);
Configuration::append(['groups' => $groups]);
}
$suites = Configuration::suites();
if( !empty($filterSuitesByWildcard)){
$suites = array_intersect($suites, $filterSuitesByWildcard);
}
if( isset($filterAppSuites[$relativePath])) {
$suites = array_intersect($suites, $filterAppSuites[$relativePath]);
}
$namespace = $this->currentNamespace();
$this->output->writeln(
"\n<fg=white;bg=magenta>\n[$namespace]: tests from $current_dir\n</fg=white;bg=magenta>"
);
$this->executed += $this->runSuites($suites, $this->options['skip']);
if (!empty($config['include'])) {
$this->runIncludedSuites($config['include'], $current_dir);
}
}
}
protected function currentNamespace()
{
$config = Configuration::config();
if (!$config['namespace']) {
throw new \RuntimeException(
"Can't include into runner suite without a namespace;\n"
. "Please add `namespace` section into included codeception.yml file"
);
}
return $config['namespace'];
}
protected function runSuites($suites, $skippedSuites = [])
{
$executed = 0;
foreach ($suites as $suite) {
if (in_array($suite, $skippedSuites)) {
continue;
}
if (!in_array($suite, Configuration::suites())) {
continue;
}
$this->codecept->run($suite);
$executed++;
}
return $executed;
}
protected function matchTestFromFilename($filename, $testsPath)
{
$filter = '';
if (strpos($filename, ':') !== false) {
if ((PHP_OS === 'Windows' || PHP_OS === 'WINNT') && $filename[1] === ':') {
// match C:\...
list($drive, $path, $filter) = explode(':', $filename, 3);
$filename = $drive . ':' . $path;
} else {
list($filename, $filter) = explode(':', $filename, 2);
}
if ($filter) {
$filter = ':' . $filter;
}
}
$testsPath = str_replace(['//', '\/', '\\'], '/', $testsPath);
$filename = str_replace(['//', '\/', '\\'], '/', $filename);
if (rtrim($filename, '/') === $testsPath) {
//codecept run tests
return ['', '', $filter];
}
$res = preg_match("~^$testsPath/(.*?)(?>/(.*))?$~", $filename, $matches);
if (!$res) {
throw new \InvalidArgumentException("Test file can't be matched");
}
if (!isset($matches[2])) {
$matches[2] = '';
}
if ($filter) {
$matches[2] .= $filter;
}
return $matches;
}
private function matchFilteredTestName(&$path)
{
$test_parts = explode(':', $path, 2);
if (count($test_parts) > 1) {
list($path, $filter) = $test_parts;
// use carat to signify start of string like in normal regex
// phpunit --filter matches against the fully qualified method name, so tests actually begin with :
$carat_pos = strpos($filter, '^');
if ($carat_pos !== false) {
$filter = substr_replace($filter, ':', $carat_pos, 1);
}
return $filter;
}
return null;
}
protected function passedOptionKeys(InputInterface $input)
{
$options = [];
$request = (string)$input;
$tokens = explode(' ', $request);
foreach ($tokens as $token) {
$token = preg_replace('~=.*~', '', $token); // strip = from options
if (empty($token)) {
continue;
}
if ($token == '--') {
break; // there should be no options after ' -- ', only arguments
}
if (substr($token, 0, 2) === '--') {
$options[] = substr($token, 2);
} elseif ($token[0] === '-') {
$shortOption = substr($token, 1);
$options[] = $this->getDefinition()->getOptionForShortcut($shortOption)->getName();
}
}
return $options;
}
protected function booleanOptions(InputInterface $input, $options = [])
{
$values = [];
$request = (string)$input;
foreach ($options as $option => $defaultValue) {
if (strpos($request, "--$option")) {
$values[$option] = $input->getOption($option) ? $input->getOption($option) : $defaultValue;
} else {
$values[$option] = false;
}
}
return $values;
}
/**
* @param string $ext
* @throws \Exception
*/
private function ensurePhpExtIsAvailable($ext)
{
if (!extension_loaded(strtolower($ext))) {
throw new \Exception(
"Codeception requires \"{$ext}\" extension installed to make tests run\n"
. "If you are not sure, how to install \"{$ext}\", please refer to StackOverflow\n\n"
. "Notice: PHP for Apache/Nginx and CLI can have different php.ini files.\n"
. "Please make sure that your PHP you run from console has \"{$ext}\" enabled."
);
}
}
/**
* @param string $suite_name
*
* @return bool
*/
private function isWildcardSuiteName($suite_name)
{
return '*::' === substr($suite_name, 0, 3);
}
/**
* @param string $suite_name
*
* @return bool
*/
private function isSuiteInMultiApplication($suite_name)
{
return false !== strpos($suite_name, '::');
}
/**
* @return array
*/
private function addRuntimeOptionsToCurrentConfig(array $config)
{
// update config from options
if (count($this->options['override'])) {
$config = $this->overrideConfig($this->options['override']);
}
// enable extensions
if ($this->options['ext']) {
$config = $this->enableExtensions($this->options['ext']);
}
return $config;
}
}
@@ -0,0 +1,122 @@
<?php
namespace Codeception\Command;
use Humbug\SelfUpdate\Updater;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Codeception\Codecept;
/**
* Auto-updates phar archive from official site: 'https://codeception.com/codecept.phar' .
*
* * `php codecept.phar self-update`
*
* @author Franck Cassedanne <franck@cassedanne.com>
*/
class SelfUpdate extends Command
{
/**
* Class constants
*/
const NAME = 'Codeception';
const GITHUB_REPO = 'Codeception/Codeception';
const PHAR_URL = 'https://codeception.com/';
const PHAR_URL_PHP56 = 'https://codeception.com/php56/';
/**
* Holds the current script filename.
* @var string
*/
protected $filename;
/**
* {@inheritdoc}
*/
protected function configure()
{
if (isset($_SERVER['argv'][0])) {
$this->filename = $_SERVER['argv'][0];
} else {
$this->filename = \Phar::running(false);
}
$this
->setAliases(array('selfupdate'))
->setDescription(
sprintf(
'Upgrade <comment>%s</comment> to the latest version',
$this->filename
)
);
parent::configure();
}
/**
* @return string
*/
protected function getCurrentVersion()
{
return Codecept::VERSION;
}
/**
* {@inheritdoc}
*/
public function execute(InputInterface $input, OutputInterface $output)
{
$version = $this->getCurrentVersion();
$output->writeln(
sprintf(
'<info>%s</info> version <comment>%s</comment>',
self::NAME,
$version
)
);
$url = $this->getPharUrl();
$updater = new Updater(null, false);
$updater->getStrategy()->setPharUrl($url . 'codecept.phar');
$updater->getStrategy()->setVersionUrl($url . 'codecept.version');
try {
if ($updater->hasUpdate()) {
$output->writeln("\n<info>Updating...</info>");
$updater->update();
$output->writeln(
sprintf("\n<comment>%s</comment> has been updated.\n", $this->filename)
);
} else {
$output->writeln('You are already using the latest version.');
}
} catch (\Exception $e) {
$output->writeln(
sprintf(
"<error>\n%s\n</error>",
$e->getMessage()
)
);
return 1;
}
return 0;
}
/**
* Returns base url of phar file for current PHP version
*
* @return string
*/
protected function getPharUrl()
{
if (version_compare(PHP_VERSION, '7.2.0', '<')) {
return self::PHAR_URL_PHP56;
}
return self::PHAR_URL;
}
}
@@ -0,0 +1,66 @@
<?php
namespace Codeception\Command\Shared;
use Codeception\Configuration;
use Symfony\Component\Console\Exception\InvalidOptionException;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
trait Config
{
protected function getSuiteConfig($suite)
{
return Configuration::suiteSettings($suite, $this->getGlobalConfig());
}
protected function getGlobalConfig($conf = null)
{
return Configuration::config($conf);
}
protected function getSuites($conf = null)
{
return Configuration::suites();
}
protected function overrideConfig($configOptions)
{
$updatedConfig = [];
foreach ($configOptions as $option) {
$keys = explode(': ', $option);
if (count($keys) < 2) {
throw new \InvalidArgumentException('--override should have config passed as "key: value"');
}
$value = array_pop($keys);
$yaml = '';
for ($ind = 0; count($keys); $ind += 2) {
$yaml .= "\n" . str_repeat(' ', $ind) . array_shift($keys) . ': ';
}
$yaml .= $value;
try {
$config = Yaml::parse($yaml);
} catch (ParseException $e) {
throw new \Codeception\Exception\ParseException("Overridden config can't be parsed: \n$yaml\n" . $e->getParsedLine());
}
$updatedConfig = array_merge_recursive($updatedConfig, $config);
}
return Configuration::append($updatedConfig);
}
protected function enableExtensions($extensions)
{
$config = ['extensions' => ['enabled' => []]];
foreach ($extensions as $name) {
if (!class_exists($name)) {
$className = 'Codeception\\Extension\\' . ucfirst($name);
if (!class_exists($className)) {
throw new InvalidOptionException("Extension $name can't be loaded (tried by $name and $className)");
}
$config['extensions']['enabled'][] = $className;
continue;
}
$config['extensions']['enabled'][] = $name;
}
return Configuration::append($config);
}
}
@@ -0,0 +1,54 @@
<?php
namespace Codeception\Command\Shared;
use Codeception\Util\Shared\Namespaces;
trait FileSystem
{
use Namespaces;
protected function createDirectoryFor($basePath, $className = '')
{
$basePath = rtrim($basePath, DIRECTORY_SEPARATOR);
if ($className) {
$className = str_replace(['/', '\\'], [DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR], $className);
$path = $basePath . DIRECTORY_SEPARATOR . $className;
$basePath = pathinfo($path, PATHINFO_DIRNAME) . DIRECTORY_SEPARATOR;
}
if (!file_exists($basePath)) {
// Second argument should be mode. Well, umask() doesn't seem to return any if not set. Config may fix this.
mkdir($basePath, 0775, true); // Third parameter commands to create directories recursively
}
return $basePath;
}
protected function completeSuffix($filename, $suffix)
{
if (strpos(strrev($filename), strrev($suffix)) === 0) {
$filename .= '.php';
}
if (strpos(strrev($filename), strrev($suffix . '.php')) !== 0) {
$filename .= $suffix . '.php';
}
if (strpos(strrev($filename), strrev('.php')) !== 0) {
$filename .= '.php';
}
return $filename;
}
protected function removeSuffix($classname, $suffix)
{
$classname = preg_replace('~\.php$~', '', $classname);
return preg_replace("~$suffix$~", '', $classname);
}
protected function createFile($filename, $contents, $force = false, $flags = 0)
{
if (file_exists($filename) && !$force) {
return false;
}
file_put_contents($filename, $contents, $flags);
return true;
}
}
@@ -0,0 +1,16 @@
<?php
namespace Codeception\Command\Shared;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
use Symfony\Component\Console\Output\OutputInterface;
trait Style
{
public function addStyles(OutputInterface $output)
{
$output->getFormatter()->setStyle('notice', new OutputFormatterStyle('white', 'green', ['bold']));
$output->getFormatter()->setStyle('bold', new OutputFormatterStyle(null, null, ['bold']));
$output->getFormatter()->setStyle('warning', new OutputFormatterStyle(null, 'yellow', ['bold']));
$output->getFormatter()->setStyle('debug', new OutputFormatterStyle('cyan'));
}
}
@@ -0,0 +1,792 @@
<?php
namespace Codeception;
use Codeception\Exception\ConfigurationException;
use Codeception\Lib\Notification;
use Codeception\Lib\ParamsLoader;
use Codeception\Util\Autoload;
use Codeception\Util\Template;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use function array_unique;
class Configuration
{
protected static $suites = [];
/**
* @var array Current configuration
*/
protected static $config = null;
/**
* @var array environmental files configuration cache
*/
protected static $envConfig = [];
/**
* @var string Directory containing main configuration file.
* @see self::projectDir()
*/
protected static $dir = null;
/**
* @var string Directory of a base configuration file for the project with includes.
* @see self::projectDir()
*/
protected static $baseDir = null;
/**
* @var string Current project output directory.
*/
protected static $outputDir = null;
/**
* @var string Current project data directory. This directory is used to hold
* sql dumps and other things needed for current project tests.
*/
protected static $dataDir = null;
/**
* @var string Directory with test support files like Actors, Helpers, PageObjects, etc
*/
protected static $supportDir = null;
/**
* @var string Directory containing environment configuration files.
*/
protected static $envsDir = null;
/**
* @var string Directory containing tests and suites of the current project.
*/
protected static $testsDir = null;
public static $lock = false;
protected static $di;
/**
* @var array Default config
*/
public static $defaultConfig = [
'actor_suffix'=> 'Tester',
'namespace' => '',
'include' => [],
'paths' => [],
'extends' => null,
'suites' => [],
'modules' => [],
'extensions' => [
'enabled' => [],
'config' => [],
'commands' => [],
],
'reporters' => [
'xml' => 'Codeception\PHPUnit\Log\JUnit',
'html' => 'Codeception\PHPUnit\ResultPrinter\HTML',
'report' => 'Codeception\PHPUnit\ResultPrinter\Report',
'tap' => 'PHPUnit\Util\Log\TAP',
'json' => 'PHPUnit\Util\Log\JSON',
'phpunit-xml' => 'Codeception\PHPUnit\Log\PhpUnit',
],
'groups' => [],
'bootstrap' => false,
'settings' => [
'colors' => true,
'bootstrap' => false,
'strict_xml' => false,
'lint' => true,
'backup_globals' => true,
'log_incomplete_skipped' => false,
'report_useless_tests' => false,
'disallow_test_output' => false,
'be_strict_about_changes_to_global_state' => false,
'shuffle' => false,
],
'coverage' => [],
'params' => [],
'gherkin' => []
];
public static $defaultSuiteSettings = [
'actor' => null,
'class_name' => null, // Codeception <2.3 compatibility
'modules' => [
'enabled' => [],
'config' => [],
'depends' => []
],
'step_decorators' => 'Codeception\Step\ConditionalAssertion',
'path' => null,
'extends' => null,
'namespace' => null,
'groups' => [],
'formats' => [],
'shuffle' => false,
'extensions' => [ // suite extensions
'enabled' => [],
'config' => [],
],
'error_level' => 'E_ALL & ~E_STRICT & ~E_DEPRECATED',
'convert_deprecations_to_exceptions' => false
];
protected static $params;
/**
* Loads global config file which is `codeception.yml` by default.
* When config is already loaded - returns it.
*
* @param null $configFile
* @return array
* @throws Exception\ConfigurationException
*/
public static function config($configFile = null)
{
if (!$configFile && self::$config) {
return self::$config;
}
if (self::$config && self::$lock) {
return self::$config;
}
if ($configFile === null) {
$configFile = getcwd() . DIRECTORY_SEPARATOR . 'codeception.yml';
}
if (is_dir($configFile)) {
$configFile = $configFile . DIRECTORY_SEPARATOR . 'codeception.yml';
}
$dir = realpath(dirname($configFile));
self::$dir = $dir;
// set the one default base directory for included setup
if (!self::$baseDir) {
self::$baseDir = $dir;
}
$configDistFile = $dir . DIRECTORY_SEPARATOR . 'codeception.dist.yml';
if (!(file_exists($configDistFile) || file_exists($configFile))) {
throw new ConfigurationException("Configuration file could not be found.\nRun `bootstrap` to initialize Codeception.", 404);
}
// Preload config to retrieve params such that they are applied to codeception config file below
$tempConfig = self::$defaultConfig;
$distConfigContents = "";
if (file_exists($configDistFile)) {
$distConfigContents = file_get_contents($configDistFile);
$tempConfig = self::mergeConfigs($tempConfig, self::getConfFromContents($distConfigContents, $configDistFile));
}
$configContents = "";
if (file_exists($configFile)) {
$configContents = file_get_contents($configFile);
$tempConfig = self::mergeConfigs($tempConfig, self::getConfFromContents($configContents, $configFile));
}
self::prepareParams($tempConfig);
// load config using params
$config = self::mergeConfigs(self::$defaultConfig, self::getConfFromContents($distConfigContents, $configDistFile));
$config = self::mergeConfigs($config, self::getConfFromContents($configContents, $configFile));
if ($config == self::$defaultConfig) {
throw new ConfigurationException("Configuration file is invalid");
}
// we check for the "extends" key in the yml file
if (isset($config['extends'])) {
// and now we search for the file
$presetFilePath = codecept_absolute_path($config['extends']);
if (file_exists($presetFilePath)) {
// and merge it with our configuration file
$config = self::mergeConfigs(self::getConfFromFile($presetFilePath), $config);
}
}
self::$config = $config;
// compatibility with suites created by Codeception < 2.3.0
if (!isset($config['paths']['output']) and isset($config['paths']['log'])) {
$config['paths']['output'] = $config['paths']['log'];
}
if (isset(self::$config['actor'])) {
self::$config['actor_suffix'] = self::$config['actor']; // old compatibility
}
if (!isset($config['paths']['support']) and isset($config['paths']['helpers'])) {
$config['paths']['support'] = $config['paths']['helpers'];
}
if (!isset($config['paths']['output'])) {
throw new ConfigurationException('Output path is not defined by key "paths: output"');
}
self::$outputDir = $config['paths']['output'];
// fill up includes with wildcard expansions
$config['include'] = self::expandWildcardedIncludes($config['include']);
// config without tests, for inclusion of other configs
if (count($config['include'])) {
self::$config = $config;
if (!isset($config['paths']['tests'])) {
return $config;
}
}
if (!isset($config['paths']['tests'])) {
throw new ConfigurationException(
'Tests directory is not defined in Codeception config by key "paths: tests:"'
);
}
if (!isset($config['paths']['data'])) {
throw new ConfigurationException('Data path is not defined Codeception config by key "paths: data"');
}
if (!isset($config['paths']['support'])) {
throw new ConfigurationException('Helpers path is not defined by key "paths: support"');
}
self::$dataDir = $config['paths']['data'];
self::$supportDir = $config['paths']['support'];
self::$testsDir = $config['paths']['tests'];
if (isset($config['paths']['envs'])) {
self::$envsDir = $config['paths']['envs'];
}
Autoload::addNamespace(self::$config['namespace'], self::supportDir());
if ($config['settings']['bootstrap']) {
$bootstrap = self::$config['settings']['bootstrap'];
Notification::deprecate("'settings: bootstrap: $bootstrap' option is deprecated! Replace it with: 'bootstrap: $bootstrap' (not under settings section). See: https://codeception.com/docs/reference/Configuration");
try {
self::loadBootstrap($bootstrap, self::testsDir());
} catch (ConfigurationException $exception) {
Notification::deprecate("Bootstrap file ($bootstrap) is defined in configuration but can't be loaded. Disable 'settings: bootstrap:' configuration to remove this message");
}
}
self::loadBootstrap($config['bootstrap'], self::testsDir());
self::loadSuites();
return $config;
}
public static function loadBootstrap($bootstrap, $path)
{
if (!$bootstrap) {
return;
}
$bootstrap = \Codeception\Util\PathResolver::isPathAbsolute($bootstrap)
? $bootstrap
: rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $bootstrap;
if (!file_exists($bootstrap)) {
throw new ConfigurationException("Bootstrap file $bootstrap can't be loaded");
}
require_once $bootstrap;
}
protected static function loadSuites()
{
$suites = Finder::create()
->files()
->name('*.{suite,suite.dist}.yml')
->in(self::$dir . DIRECTORY_SEPARATOR . self::$testsDir)
->depth('< 1')
->sortByName();
self::$suites = [];
foreach (array_keys(self::$config['suites']) as $suite) {
self::$suites[$suite] = $suite;
}
/** @var SplFileInfo $suite */
foreach ($suites as $suite) {
preg_match('~(.*?)(\.suite|\.suite\.dist)\.yml~', $suite->getFilename(), $matches);
self::$suites[$matches[1]] = $matches[1];
}
}
/**
* Returns suite configuration. Requires suite name and global config used (Configuration::config)
*
* @param string $suite
* @param array $config
* @return array
* @throws \Exception
*/
public static function suiteSettings($suite, $config)
{
// cut namespace name from suite name
if ($suite != $config['namespace'] && substr($suite, 0, strlen($config['namespace'])) == $config['namespace']) {
$suite = substr($suite, strlen($config['namespace']));
}
if (!in_array($suite, self::$suites)) {
throw new ConfigurationException("Suite $suite was not loaded");
}
// load global config
$globalConf = $config['settings'];
foreach (['modules', 'coverage', 'namespace', 'groups', 'env', 'gherkin', 'extensions'] as $key) {
if (isset($config[$key])) {
$globalConf[$key] = $config[$key];
}
}
$settings = self::mergeConfigs(self::$defaultSuiteSettings, $globalConf);
// load suite config
$settings = self::loadSuiteConfig($suite, $config['paths']['tests'], $settings);
// load from environment configs
if (isset($config['paths']['envs'])) {
$envConf = self::loadEnvConfigs(self::$dir . DIRECTORY_SEPARATOR . $config['paths']['envs']);
$settings = self::mergeConfigs($settings, $envConf);
}
if (!$settings['actor']) {
// Codeception 2.2 compatibility
$settings['actor'] = $settings['class_name'];
}
if (!$settings['path']) {
// take a suite path from its name
$settings['path'] = $suite;
}
$config['paths']['tests'] = str_replace('/', DIRECTORY_SEPARATOR, $config['paths']['tests']);
$settings['path'] = self::$dir . DIRECTORY_SEPARATOR . $config['paths']['tests']
. DIRECTORY_SEPARATOR . $settings['path'] . DIRECTORY_SEPARATOR;
return $settings;
}
/**
* Loads environments configuration from set directory
*
* @param string $path path to the directory
* @return array
*/
protected static function loadEnvConfigs($path)
{
if (isset(self::$envConfig[$path])) {
return self::$envConfig[$path];
}
if (!is_dir($path)) {
self::$envConfig[$path] = [];
return self::$envConfig[$path];
}
$envFiles = Finder::create()
->files()
->name('*.yml')
->in($path)
->depth('< 2');
$envConfig = [];
/** @var SplFileInfo $envFile */
foreach ($envFiles as $envFile) {
$env = str_replace(['.dist.yml', '.yml'], '', $envFile->getFilename());
$envConfig[$env] = [];
$envPath = $path;
if ($envFile->getRelativePath()) {
$envPath .= DIRECTORY_SEPARATOR . $envFile->getRelativePath();
}
foreach (['.dist.yml', '.yml'] as $suffix) {
$envConf = self::getConfFromFile($envPath . DIRECTORY_SEPARATOR . $env . $suffix, null);
if ($envConf === null) {
continue;
}
$envConfig[$env] = self::mergeConfigs($envConfig[$env], $envConf);
}
}
self::$envConfig[$path] = ['env' => $envConfig];
return self::$envConfig[$path];
}
/**
* Loads configuration from Yaml data
*
* @param string $contents Yaml config file contents
* @param string $filename which is supposed to be loaded
* @return array
* @throws ConfigurationException
*/
protected static function getConfFromContents($contents, $filename = '(.yml)')
{
if (self::$params) {
$template = new Template($contents, '%', '%');
$template->setVars(self::$params);
$contents = $template->produce();
}
try {
return Yaml::parse($contents);
} catch (ParseException $exception) {
throw new ConfigurationException(
sprintf(
"Error loading Yaml config from `%s`\n \n%s\nRead more about Yaml format https://goo.gl/9UPuEC",
$filename,
$exception->getMessage()
)
);
}
}
/**
* Loads configuration from Yaml file or returns given value if the file doesn't exist
*
* @param string $filename filename
* @param mixed $nonExistentValue value used if filename is not found
* @return array
* @throws ConfigurationException
*/
protected static function getConfFromFile($filename, $nonExistentValue = [])
{
if (file_exists($filename)) {
$yaml = file_get_contents($filename);
return self::getConfFromContents($yaml, $filename);
}
return $nonExistentValue;
}
public static function suites()
{
return self::$suites;
}
/**
* Return list of enabled modules according suite config.
*
* @param array $settings suite settings
* @return array
*/
public static function modules($settings)
{
return array_filter(
array_map(
function ($m) {
return is_array($m) ? key($m) : $m;
},
$settings['modules']['enabled'],
array_keys($settings['modules']['enabled'])
),
function ($m) use ($settings) {
if (!isset($settings['modules']['disabled'])) {
return true;
}
return !in_array($m, $settings['modules']['disabled']);
}
);
}
public static function isExtensionEnabled($extensionName)
{
return isset(self::$config['extensions']['enabled'])
&& in_array($extensionName, self::$config['extensions']['enabled']);
}
/**
* Returns current path to `_data` dir.
* Use it to store database fixtures, sql dumps, or other files required by your tests.
*
* @return string
*/
public static function dataDir()
{
return self::$dir . DIRECTORY_SEPARATOR . self::$dataDir . DIRECTORY_SEPARATOR;
}
/**
* Return current path to `_helpers` dir.
* Helpers are custom modules.
*
* @return string
*/
public static function supportDir()
{
return self::$dir . DIRECTORY_SEPARATOR . self::$supportDir . DIRECTORY_SEPARATOR;
}
/**
* Returns actual path to current `_output` dir.
* Use it in Helpers or Groups to save result or temporary files.
*
* @return string
* @throws Exception\ConfigurationException
*/
public static function outputDir()
{
if (!self::$outputDir) {
throw new ConfigurationException("Path for output not specified. Please, set output path in global config");
}
$dir = self::$outputDir . DIRECTORY_SEPARATOR;
if (!codecept_is_path_absolute($dir)) {
$dir = self::$dir . DIRECTORY_SEPARATOR . $dir;
}
if (!file_exists($dir)) {
@mkdir($dir, 0777, true);
}
if (!is_writable($dir)) {
@chmod($dir, 0777);
}
if (!is_writable($dir)) {
throw new ConfigurationException(
"Path for output is not writable. Please, set appropriate access mode for output path: {$dir}"
);
}
return $dir;
}
/**
* Compatibility alias to `Configuration::logDir()`
* @return string
* @throws ConfigurationException
*/
public static function logDir()
{
return self::outputDir();
}
/**
* Returns path to the root of your project.
* Basically returns path to current `codeception.yml` loaded.
* Use this method instead of `__DIR__`, `getcwd()` or anything else.
* @return string
*/
public static function projectDir()
{
return self::$dir . DIRECTORY_SEPARATOR;
}
/**
* Returns path to the base dir for config which consists with included setup
* Returns path to `codeception.yml` which was executed.
* If config doesn't have "include" section the result is the same as `projectDir()`
* @return string
*/
public static function baseDir()
{
return self::$baseDir . DIRECTORY_SEPARATOR;
}
/**
* Returns path to tests directory
*
* @return string
*/
public static function testsDir()
{
return self::$dir . DIRECTORY_SEPARATOR . self::$testsDir . DIRECTORY_SEPARATOR;
}
/**
* Return current path to `_envs` dir.
* Use it to store environment specific configuration.
*
* @return string
*/
public static function envsDir()
{
if (!self::$envsDir) {
return null;
}
return self::$dir . DIRECTORY_SEPARATOR . self::$envsDir . DIRECTORY_SEPARATOR;
}
/**
* Is this a meta-configuration file that just points to other `codeception.yml`?
* If so, it may have no tests by itself.
*
* @return bool
*/
public static function isEmpty()
{
return !(bool)self::$testsDir;
}
/**
* Adds parameters to config
*
* @param array $config
* @return array
*/
public static function append(array $config = [])
{
self::$config = self::mergeConfigs(self::$config, $config);
if (isset(self::$config['paths']['output'])) {
self::$outputDir = self::$config['paths']['output'];
}
if (isset(self::$config['paths']['data'])) {
self::$dataDir = self::$config['paths']['data'];
}
if (isset(self::$config['paths']['support'])) {
self::$supportDir = self::$config['paths']['support'];
}
if (isset(self::$config['paths']['tests'])) {
self::$testsDir = self::$config['paths']['tests'];
}
return self::$config;
}
public static function mergeConfigs($a1, $a2)
{
if (!is_array($a1)) {
return $a2;
}
if (!is_array($a2)) {
return $a1;
}
$res = [];
// for sequential arrays
if (isset($a1[0], $a2[0])) {
return array_values(array_unique(array_merge_recursive($a2, $a1), SORT_REGULAR));
}
// for associative arrays
foreach ($a2 as $k2 => $v2) {
if (!isset($a1[$k2])) { // if no such key
$res[$k2] = $v2;
unset($a1[$k2]);
continue;
}
$res[$k2] = self::mergeConfigs($a1[$k2], $v2);
unset($a1[$k2]);
}
foreach ($a1 as $k1 => $v1) { // only single elements here left
$res[$k1] = $v1;
}
return $res;
}
/**
* Loads config from *.dist.suite.yml and *.suite.yml
*
* @param $suite
* @param $path
* @param $settings
* @return array
* @throws ConfigurationException
*/
protected static function loadSuiteConfig($suite, $path, $settings)
{
if (isset(self::$config['suites'][$suite])) {
// bundled config
return self::mergeConfigs($settings, self::$config['suites'][$suite]);
}
$suiteDir = self::$dir . DIRECTORY_SEPARATOR . $path;
$suiteDistConf = self::getConfFromFile($suiteDir . DIRECTORY_SEPARATOR . "$suite.suite.dist.yml");
$suiteConf = self::getConfFromFile($suiteDir . DIRECTORY_SEPARATOR . "$suite.suite.yml");
// now we check the suite config file, if a extends key is defined
if (isset($suiteConf['extends'])) {
$presetFilePath = codecept_is_path_absolute($suiteConf['extends'])
? $suiteConf['extends'] // If path is absolute – use it
: realpath($suiteDir . DIRECTORY_SEPARATOR . $suiteConf['extends']); // Otherwise try to locate it in the suite dir
if (file_exists($presetFilePath)) {
$settings = self::mergeConfigs(self::getConfFromFile($presetFilePath), $settings);
}
}
$settings = self::mergeConfigs($settings, $suiteDistConf);
$settings = self::mergeConfigs($settings, $suiteConf);
return $settings;
}
/**
* Replaces wildcarded items in include array with real paths.
*
* @param $includes
* @return array
* @throws ConfigurationException
*/
protected static function expandWildcardedIncludes(array $includes)
{
if (empty($includes)) {
return $includes;
}
$expandedIncludes = [];
foreach ($includes as $include) {
$expandedIncludes = array_merge($expandedIncludes, self::expandWildcardsFor($include));
}
return $expandedIncludes;
}
/**
* Finds config files in given wildcarded include path.
* Returns the expanded paths or the original if not a wildcard.
*
* @param $include
* @return array
* @throws ConfigurationException
*/
protected static function expandWildcardsFor($include)
{
if (1 !== preg_match('/[\?\.\*]/', $include)) {
return [$include,];
}
try {
$configFiles = Finder::create()->files()
->name('/codeception(\.dist\.yml|\.yml)/')
->in(self::$dir . DIRECTORY_SEPARATOR . $include);
} catch (\InvalidArgumentException $e) {
throw new ConfigurationException(
"Configuration file(s) could not be found in \"$include\"."
);
}
$paths = [];
foreach ($configFiles as $file) {
$paths[] = codecept_relative_path($file->getPath());
}
return array_unique($paths);
}
private static function prepareParams($settings)
{
self::$params = [];
$paramsLoader = new ParamsLoader();
foreach ($settings['params'] as $paramStorage) {
static::$params = array_merge(self::$params, $paramsLoader->load($paramStorage));
}
}
}
@@ -0,0 +1,193 @@
<?php
namespace Codeception\Coverage;
use Codeception\Configuration;
use Codeception\Exception\ConfigurationException;
use Codeception\Exception\ModuleException;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
use Symfony\Component\Finder\Finder;
class Filter
{
/**
* @var CodeCoverage
*/
protected $phpCodeCoverage = null;
/**
* @var Filter
*/
protected static $c3;
/**
* @var \SebastianBergmann\CodeCoverage\Filter
*/
protected $filter = null;
public function __construct(CodeCoverage $phpCoverage)
{
$this->phpCodeCoverage = $phpCoverage;
$this->filter = $this->phpCodeCoverage->filter();
}
/**
* @param CodeCoverage $phpCoverage
* @return Filter
*/
public static function setup(CodeCoverage $phpCoverage)
{
self::$c3 = new self($phpCoverage);
return self::$c3;
}
/**
* @return null|CodeCoverage
*/
public function getPhpCodeCoverage()
{
return $this->phpCodeCoverage;
}
/**
* @param $config
* @return Filter
*/
public function whiteList($config)
{
$filter = $this->filter;
if (!isset($config['coverage'])) {
return $this;
}
$coverage = $config['coverage'];
if (!isset($coverage['whitelist'])) {
$coverage['whitelist'] = [];
if (isset($coverage['include'])) {
$coverage['whitelist']['include'] = $coverage['include'];
}
if (isset($coverage['exclude'])) {
$coverage['whitelist']['exclude'] = $coverage['exclude'];
}
}
if (isset($coverage['whitelist']['include'])) {
if (!is_array($coverage['whitelist']['include'])) {
throw new ConfigurationException('Error parsing yaml. Config `whitelist: include:` should be an array');
}
foreach ($coverage['whitelist']['include'] as $fileOrDir) {
$finder = strpos($fileOrDir, '*') === false
? [Configuration::projectDir() . DIRECTORY_SEPARATOR . $fileOrDir]
: $this->matchWildcardPattern($fileOrDir);
foreach ($finder as $file) {
if (method_exists($filter, 'addFileToWhitelist')) {
//php-code-coverage 8 or older
$filter->addFileToWhitelist($file);
} else {
//php-code-coverage 9+
$filter->includeFile($file);
}
}
}
}
if (isset($coverage['whitelist']['exclude'])) {
if (!is_array($coverage['whitelist']['exclude'])) {
throw new ConfigurationException('Error parsing yaml. Config `whitelist: exclude:` should be an array');
}
foreach ($coverage['whitelist']['exclude'] as $fileOrDir) {
try {
$finder = strpos($fileOrDir, '*') === false
? [Configuration::projectDir() . DIRECTORY_SEPARATOR . $fileOrDir]
: $this->matchWildcardPattern($fileOrDir);
foreach ($finder as $file) {
if (method_exists($filter, 'removeFileFromWhitelist')) {
//php-code-coverage 8 or older
$filter->removeFileFromWhitelist($file);
} else {
//php-code-coverage 9+
$filter->excludeFile($file);
}
}
} catch (DirectoryNotFoundException $e) {
continue;
}
}
}
return $this;
}
/**
* @param $config
* @return Filter
*/
public function blackList($config)
{
$filter = $this->filter;
if (!isset($config['coverage'])) {
return $this;
}
$coverage = $config['coverage'];
if (isset($coverage['blacklist'])) {
if (!method_exists($filter, 'addFileToBlacklist')) {
throw new ModuleException($this, 'The blacklist functionality has been removed from PHPUnit 5,'
. ' please remove blacklist section from configuration.');
}
if (isset($coverage['blacklist']['include'])) {
foreach ($coverage['blacklist']['include'] as $fileOrDir) {
$finder = strpos($fileOrDir, '*') === false
? [Configuration::projectDir() . DIRECTORY_SEPARATOR . $fileOrDir]
: $this->matchWildcardPattern($fileOrDir);
foreach ($finder as $file) {
$filter->addFileToBlacklist($file);
}
}
}
if (isset($coverage['blacklist']['exclude'])) {
foreach ($coverage['blacklist']['exclude'] as $fileOrDir) {
$finder = strpos($fileOrDir, '*') === false
? [Configuration::projectDir() . DIRECTORY_SEPARATOR . $fileOrDir]
: $this->matchWildcardPattern($fileOrDir);
foreach ($finder as $file) {
$filter->removeFileFromBlacklist($file);
}
}
}
}
return $this;
}
protected function matchWildcardPattern($pattern)
{
$finder = Finder::create();
$fileOrDir = str_replace('\\', '/', $pattern);
$parts = explode('/', $fileOrDir);
$file = array_pop($parts);
if ($file === '*') {
$file = '*.php';
}
$finder->name($file);
if (count($parts)) {
$last_path = array_pop($parts);
if ($last_path === '*') {
$finder->in(Configuration::projectDir() . implode('/', $parts));
} else {
$finder->in(Configuration::projectDir() . implode('/', $parts) . '/' . $last_path);
}
}
$finder->ignoreVCS(true)->files();
return $finder;
}
/**
* @return \SebastianBergmann\CodeCoverage\Filter
*/
public function getFilter()
{
return $this->filter;
}
}
@@ -0,0 +1,23 @@
<?php
namespace Codeception\Coverage;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\Driver;
use SebastianBergmann\CodeCoverage\Filter as CodeCoverageFilter;
class PhpCodeCoverageFactory
{
public static function build()
{
if (method_exists(Driver::class, 'forLineCoverage')) {
//php-code-coverage 9+
$filter = new CodeCoverageFilter();
$driver = Driver::forLineCoverage($filter);
return new CodeCoverage($driver, $filter);
} else {
//php-code-coverage 8 or older
return new CodeCoverage();
}
}
}
@@ -0,0 +1,47 @@
<?php
namespace Codeception\Coverage\Subscriber;
use Codeception\Coverage\SuiteSubscriber;
use Codeception\Event\SuiteEvent;
use Codeception\Events;
use Codeception\Lib\Interfaces\Remote;
/**
* Collects code coverage from unit and functional tests.
* Results from all suites are merged.
*/
class Local extends SuiteSubscriber
{
public static $events = [
Events::SUITE_BEFORE => 'beforeSuite',
Events::SUITE_AFTER => 'afterSuite',
];
/**
* @var Remote
*/
protected $module;
protected function isEnabled()
{
return $this->module === null and $this->settings['enabled'];
}
public function beforeSuite(SuiteEvent $e)
{
$this->applySettings($e->getSettings());
$this->module = $this->getServerConnectionModule($e->getSuite()->getModules());
if (!$this->isEnabled()) {
return;
}
$this->applyFilter($e->getResult());
}
public function afterSuite(SuiteEvent $e)
{
if (!$this->isEnabled()) {
return;
}
$this->mergeToPrint($e->getResult()->getCodeCoverage());
}
}
@@ -0,0 +1,296 @@
<?php
namespace Codeception\Coverage\Subscriber;
use Codeception\Configuration;
use Codeception\Coverage\SuiteSubscriber;
use Codeception\Event\StepEvent;
use Codeception\Event\SuiteEvent;
use Codeception\Event\TestEvent;
use Codeception\Events;
use Codeception\Exception\ModuleException;
use Codeception\Exception\RemoteException;
use Facebook\WebDriver\Exception\NoSuchAlertException;
/**
* When collecting code coverage data from local server HTTP requests are sent to c3.php file.
* Coverage Collection is started by sending cookies/headers.
* Result is taken from the local file and merged with local code coverage results.
*
* Class LocalServer
* @package Codeception\Coverage\Subscriber
*/
class LocalServer extends SuiteSubscriber
{
// headers
const COVERAGE_HEADER = 'X-Codeception-CodeCoverage';
const COVERAGE_HEADER_ERROR = 'X-Codeception-CodeCoverage-Error';
const COVERAGE_HEADER_CONFIG = 'X-Codeception-CodeCoverage-Config';
const COVERAGE_HEADER_SUITE = 'X-Codeception-CodeCoverage-Suite';
// cookie names
const COVERAGE_COOKIE = 'CODECEPTION_CODECOVERAGE';
const COVERAGE_COOKIE_ERROR = 'CODECEPTION_CODECOVERAGE_ERROR';
protected $suiteName;
protected $c3Access = [
'http' => [
'method' => "GET",
'header' => ''
]
];
/**
* @var \Codeception\Lib\Interfaces\Web
*/
protected $module;
public static $events = [
Events::SUITE_BEFORE => 'beforeSuite',
Events::TEST_BEFORE => 'beforeTest',
Events::STEP_AFTER => 'afterStep',
Events::SUITE_AFTER => 'afterSuite',
];
protected function isEnabled()
{
return $this->module && !$this->settings['remote'] && $this->settings['enabled'];
}
public function beforeSuite(SuiteEvent $e)
{
$this->module = $this->getServerConnectionModule($e->getSuite()->getModules());
$this->applySettings($e->getSettings());
if (!$this->isEnabled()) {
return;
}
$this->suiteName = $e->getSuite()->getBaseName();
if ($this->settings['remote_config']) {
$this->addC3AccessHeader(self::COVERAGE_HEADER_CONFIG, $this->settings['remote_config']);
$knock = $this->c3Request('clear');
if ($knock === false) {
throw new RemoteException(
'
CodeCoverage Error.
Check the file "c3.php" is included in your application.
We tried to access "/c3/report/clear" but this URI was not accessible.
You can review actual error messages in c3tmp dir.
'
);
}
}
}
public function beforeTest(TestEvent $e)
{
if (!$this->isEnabled()) {
return;
}
$this->startCoverageCollection($e->getTest()->getName());
}
public function afterStep(StepEvent $e)
{
if (!$this->isEnabled()) {
return;
}
$this->fetchErrors();
}
public function afterSuite(SuiteEvent $e)
{
if (!$this->isEnabled()) {
return;
}
$coverageFile = Configuration::outputDir() . 'c3tmp/codecoverage.serialized';
$retries = 5;
while (!file_exists($coverageFile) && --$retries >= 0) {
usleep(0.5 * 1000000); // 0.5 sec
}
if (!file_exists($coverageFile)) {
if (file_exists(Configuration::outputDir() . 'c3tmp/error.txt')) {
throw new \RuntimeException(file_get_contents(Configuration::outputDir() . 'c3tmp/error.txt'));
}
return;
}
$contents = file_get_contents($coverageFile);
$coverage = @unserialize($contents);
if ($coverage === false) {
return;
}
$this->preProcessCoverage($coverage)
->mergeToPrint($coverage);
}
/**
* Allows Translating Remote Paths To Local (IE: When Using Docker)
*
* @param \SebastianBergmann\CodeCoverage\CodeCoverage $coverage
* @return $this
*/
protected function preProcessCoverage($coverage)
{
//Only Process If Work Directory Set
if ($this->settings['work_dir'] === null) {
return $this;
}
$workDir = rtrim($this->settings['work_dir'], '/\\') . DIRECTORY_SEPARATOR;
$projectDir = Configuration::projectDir();
$data = $coverage->getData(true); //We only want covered files, not all whitelisted ones.
codecept_debug("Replacing all instances of {$workDir} with {$projectDir}");
foreach ($data as $path => $datum) {
unset($data[$path]);
$path = str_replace($workDir, $projectDir, $path);
$data[$path] = $datum;
}
$coverage->setData($data);
return $this;
}
protected function c3Request($action)
{
$this->addC3AccessHeader(self::COVERAGE_HEADER, 'remote-access');
$context = stream_context_create($this->c3Access);
$c3Url = $this->settings['c3_url'] ? $this->settings['c3_url'] : $this->module->_getUrl();
$contents = file_get_contents($c3Url . '/c3/report/' . $action, false, $context);
$okHeaders = array_filter(
$http_response_header,
function ($h) {
return preg_match('~^HTTP(.*?)\s200~', $h);
}
);
if (empty($okHeaders)) {
throw new RemoteException("Request was not successful. See response header: " . $http_response_header[0]);
}
if ($contents === false) {
$this->getRemoteError($http_response_header);
}
return $contents;
}
protected function startCoverageCollection($testName)
{
$value = [
'CodeCoverage' => $testName,
'CodeCoverage_Suite' => $this->suiteName,
'CodeCoverage_Config' => $this->settings['remote_config']
];
$value = json_encode($value);
if ($this->module instanceof \Codeception\Module\WebDriver) {
$this->module->amOnPage('/');
}
$cookieDomain = isset($this->settings['cookie_domain']) ? $this->settings['cookie_domain'] : null;
if (!$cookieDomain) {
$c3Url = parse_url($this->settings['c3_url'] ? $this->settings['c3_url'] : $this->module->_getUrl());
// we need to separate coverage cookies by host; we can't separate cookies by port.
$cookieDomain = isset($c3Url['host']) ? $c3Url['host'] : 'localhost';
}
$cookieParams = [];
if ($cookieDomain !== 'localhost') {
$cookieParams['domain'] = $cookieDomain;
}
$this->module->setCookie(self::COVERAGE_COOKIE, $value, $cookieParams);
// putting in configuration ensures the cookie is used for all sessions of a MultiSession test
$cookies = $this->module->_getConfig('cookies');
if (!$cookies || !is_array($cookies)) {
$cookies = [];
}
$found = false;
foreach ($cookies as &$cookie) {
if (!is_array($cookie) || !array_key_exists('Name', $cookie) || !array_key_exists('Value', $cookie)) {
// \Codeception\Lib\InnerBrowser will complain about this
continue;
}
if ($cookie['Name'] === self::COVERAGE_COOKIE) {
$found = true;
$cookie['Value'] = $value;
break;
}
}
unset($cookie);
if (!$found) {
$cookies[] = [
'Name' => self::COVERAGE_COOKIE,
'Value' => $value
];
}
$this->module->_setConfig(['cookies' => $cookies]);
}
protected function fetchErrors()
{
// Calling grabCookie() while an alert is present dismisses the alert
// @see https://github.com/Codeception/Codeception/issues/1485
if ($this->module instanceof \Codeception\Module\WebDriver) {
try {
$alert = $this->module->webDriver->switchTo()->alert();
$alert->getText();
// If this succeeds an alert is present, abort
return;
} catch (NoSuchAlertException $e) {
// No alert present, continue
}
}
try {
$error = $this->module->grabCookie(self::COVERAGE_COOKIE_ERROR);
} catch (ModuleException $e) {
// when a new session is started we can't get cookies because there is no
// current page, but there can be no code coverage error either
$error = null;
}
if (!empty($error)) {
$this->module->resetCookie(self::COVERAGE_COOKIE_ERROR);
throw new RemoteException($error);
}
}
protected function getRemoteError($headers)
{
foreach ($headers as $header) {
if (strpos($header, self::COVERAGE_HEADER_ERROR) === 0) {
throw new RemoteException($header);
}
}
}
protected function addC3AccessHeader($header, $value)
{
$headerString = "$header: $value\r\n";
if (strpos($this->c3Access['http']['header'], $headerString) === false) {
$this->c3Access['http']['header'] .= $headerString;
}
}
protected function applySettings($settings)
{
parent::applySettings($settings);
if (isset($settings['coverage']['remote_context_options'])) {
$this->c3Access = array_replace_recursive($this->c3Access, $settings['coverage']['remote_context_options']);
}
}
}
@@ -0,0 +1,168 @@
<?php
namespace Codeception\Coverage\Subscriber;
use Codeception\Configuration;
use Codeception\Coverage\Filter;
use Codeception\Coverage\PhpCodeCoverageFactory;
use Codeception\Event\PrintResultEvent;
use Codeception\Events;
use Codeception\Exception\ConfigurationException;
use Codeception\Subscriber\Shared\StaticEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class Printer implements EventSubscriberInterface
{
use StaticEvents;
public static $events = [
Events::RESULT_PRINT_AFTER => 'printResult'
];
protected $settings = [
'enabled' => true,
'low_limit' => '35',
'high_limit' => '70',
'show_uncovered' => false,
'show_only_summary' => false
];
public static $coverage;
protected $options;
protected $logDir;
protected $destination = [];
public function __construct($options)
{
$this->options = $options;
$this->logDir = Configuration::outputDir();
$this->settings = array_merge($this->settings, Configuration::config()['coverage']);
self::$coverage = PhpCodeCoverageFactory::build();
// Apply filter
$filter = new Filter(self::$coverage);
$filter
->whiteList(Configuration::config())
->blackList(Configuration::config());
}
protected function absolutePath($path)
{
if ((strpos($path, '/') === 0) || (strpos($path, ':') === 1)) { // absolute path
return $path;
}
return $this->logDir . $path;
}
public function printResult(PrintResultEvent $e)
{
$printer = $e->getPrinter();
if (!$this->settings['enabled']) {
$printer->write("\nCodeCoverage is disabled in `codeception.yml` config\n");
return;
}
if (!$this->options['quiet']) {
$this->printConsole($printer);
}
$printer->write("Remote CodeCoverage reports are not printed to console\n");
$this->printPHP();
$printer->write("\n");
if ($this->options['coverage-html']) {
$this->printHtml();
$printer->write("HTML report generated in {$this->options['coverage-html']}\n");
}
if ($this->options['coverage-xml']) {
$this->printXml();
$printer->write("XML report generated in {$this->options['coverage-xml']}\n");
}
if ($this->options['coverage-text']) {
$this->printText();
$printer->write("Text report generated in {$this->options['coverage-text']}\n");
}
if ($this->options['coverage-crap4j']) {
$this->printCrap4j();
$printer->write("Crap4j report generated in {$this->options['coverage-crap4j']}\n");
}
if ($this->options['coverage-cobertura']) {
$this->printCobertura();
$printer->write("Cobertura report generated in {$this->options['coverage-cobertura']}\n");
}
if ($this->options['coverage-phpunit']) {
$this->printPHPUnit();
$printer->write("PHPUnit report generated in {$this->options['coverage-phpunit']}\n");
}
}
protected function printConsole(\PHPUnit\Util\Printer $printer)
{
$writer = new \SebastianBergmann\CodeCoverage\Report\Text(
$this->settings['low_limit'],
$this->settings['high_limit'],
$this->settings['show_uncovered'],
$this->settings['show_only_summary']
);
$printer->write($writer->process(self::$coverage, $this->options['colors']));
}
protected function printHtml()
{
$writer = new \SebastianBergmann\CodeCoverage\Report\Html\Facade(
$this->settings['low_limit'],
$this->settings['high_limit'],
sprintf(
', <a href="https://codeception.com">Codeception</a> and <a href="https://phpunit.de/">PHPUnit %s</a>',
\PHPUnit\Runner\Version::id()
)
);
$writer->process(self::$coverage, $this->absolutePath($this->options['coverage-html']));
}
protected function printXml()
{
$writer = new \SebastianBergmann\CodeCoverage\Report\Clover();
$writer->process(self::$coverage, $this->absolutePath($this->options['coverage-xml']));
}
protected function printPHP()
{
$writer = new \SebastianBergmann\CodeCoverage\Report\PHP;
$writer->process(self::$coverage, $this->absolutePath($this->options['coverage']));
}
protected function printText()
{
$writer = new \SebastianBergmann\CodeCoverage\Report\Text(
$this->settings['low_limit'],
$this->settings['high_limit'],
$this->settings['show_uncovered'],
$this->settings['show_only_summary']
);
file_put_contents(
$this->absolutePath($this->options['coverage-text']),
$writer->process(self::$coverage, false)
);
}
protected function printCrap4j()
{
$writer = new \SebastianBergmann\CodeCoverage\Report\Crap4j;
$writer->process(self::$coverage, $this->absolutePath($this->options['coverage-crap4j']));
}
protected function printCobertura()
{
if (!class_exists(\SebastianBergmann\CodeCoverage\Report\Cobertura::class)) {
throw new ConfigurationException("Cobertura report requires php-code-coverage >= 9.2");
}
$writer = new \SebastianBergmann\CodeCoverage\Report\Cobertura;
$writer->process(self::$coverage, $this->absolutePath($this->options['coverage-cobertura']));
}
protected function printPHPUnit()
{
$writer = new \SebastianBergmann\CodeCoverage\Report\Xml\Facade(\PHPUnit\Runner\Version::id());
$writer->process(self::$coverage, $this->absolutePath($this->options['coverage-phpunit']));
}
}
@@ -0,0 +1,99 @@
<?php
namespace Codeception\Coverage\Subscriber;
use Codeception\Configuration;
use Codeception\Event\SuiteEvent;
use Codeception\Util\FileSystem;
/**
* When collecting code coverage on remote server
* data is retrieved over HTTP and not merged with the local code coverage results.
*
* Class RemoteServer
* @package Codeception\Coverage\Subscriber
*/
class RemoteServer extends LocalServer
{
public function isEnabled()
{
return $this->module and $this->settings['remote'] and $this->settings['enabled'];
}
public function afterSuite(SuiteEvent $e)
{
if (!$this->isEnabled()) {
return;
}
$suite = strtr($e->getSuite()->getName(), ['\\' => '.']);
if ($this->options['coverage-xml']) {
$this->retrieveAndPrintXml($suite);
}
if ($this->options['coverage-html']) {
$this->retrieveAndPrintHtml($suite);
}
if ($this->options['coverage-crap4j']) {
$this->retrieveAndPrintCrap4j($suite);
}
if ($this->options['coverage-cobertura']) {
$this->retrieveAndPrintCobertura($suite);
}
if ($this->options['coverage-phpunit']) {
$this->retrieveAndPrintPHPUnit($suite);
}
}
protected function retrieveAndPrintHtml($suite)
{
$tempFile = tempnam(sys_get_temp_dir(), 'C3') . '.tar';
file_put_contents($tempFile, $this->c3Request('html'));
$destDir = Configuration::outputDir() . $suite . '.remote.coverage';
if (is_dir($destDir)) {
FileSystem::doEmptyDir($destDir);
} else {
mkdir($destDir, 0777, true);
}
$phar = new \PharData($tempFile);
$phar->extractTo($destDir);
unlink($tempFile);
}
protected function retrieveAndPrintXml($suite)
{
$destFile = Configuration::outputDir() . $suite . '.remote.coverage.xml';
file_put_contents($destFile, $this->c3Request('clover'));
}
protected function retrieveAndPrintCrap4j($suite)
{
$destFile = Configuration::outputDir() . $suite . '.remote.crap4j.xml';
file_put_contents($destFile, $this->c3Request('crap4j'));
}
protected function retrieveAndPrintCobertura($suite)
{
$destFile = Configuration::outputDir() . $suite . '.remote.cobertura.xml';
file_put_contents($destFile, $this->c3Request('cobertura'));
}
protected function retrieveAndPrintPHPUnit($suite)
{
$tempFile = tempnam(sys_get_temp_dir(), 'C3') . '.tar';
file_put_contents($tempFile, $this->c3Request('phpunit'));
$destDir = Configuration::outputDir() . $suite . '.remote.coverage-phpunit';
if (is_dir($destDir)) {
FileSystem::doEmptyDir($destDir);
} else {
mkdir($destDir, 0777, true);
}
$phar = new \PharData($tempFile);
$phar->extractTo($destDir);
unlink($tempFile);
}
}
@@ -0,0 +1,108 @@
<?php
namespace Codeception\Coverage;
use Codeception\Configuration;
use Codeception\Coverage\Subscriber\Printer;
use Codeception\Lib\Interfaces\Remote;
use Codeception\Stub;
use Codeception\Subscriber\Shared\StaticEvents;
use PHPUnit\Framework\CodeCoverageException;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Filter as CodeCoverageFilter;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
abstract class SuiteSubscriber implements EventSubscriberInterface
{
use StaticEvents;
protected $defaultSettings = [
'enabled' => false,
'remote' => false,
'local' => false,
'xdebug_session' => 'codeception',
'remote_config' => null,
'show_uncovered' => false,
'c3_url' => null,
'work_dir' => null,
'cookie_domain' => null,
];
protected $settings = [];
protected $filters = [];
protected $modules = [];
protected $coverage;
protected $logDir;
protected $options;
public static $events = [];
abstract protected function isEnabled();
public function __construct($options = [])
{
$this->options = $options;
$this->logDir = Configuration::outputDir();
}
protected function applySettings($settings)
{
try {
$this->coverage = PhpCodeCoverageFactory::build();
} catch (CodeCoverageException $e) {
throw new \Exception(
'XDebug is required to collect CodeCoverage. Please install xdebug extension and enable it in php.ini'
);
}
$this->filters = $settings;
$this->settings = $this->defaultSettings;
$keys = array_keys($this->defaultSettings);
foreach ($keys as $key) {
if (isset($settings['coverage'][$key])) {
$this->settings[$key] = $settings['coverage'][$key];
}
}
if (method_exists($this->coverage, 'setProcessUncoveredFilesFromWhitelist')) {
//php-code-coverage 8 or older
$this->coverage->setProcessUncoveredFilesFromWhitelist($this->settings['show_uncovered']);
} else {
//php-code-coverage 9+
if ($this->settings['show_uncovered']) {
$this->coverage->processUncoveredFiles();
} else {
$this->coverage->doNotProcessUncoveredFiles();
}
}
}
/**
* @param array $modules
* @return \Codeception\Lib\Interfaces\Remote|null
*/
protected function getServerConnectionModule(array $modules)
{
foreach ($modules as $module) {
if ($module instanceof Remote) {
return $module;
}
}
return null;
}
public function applyFilter(\PHPUnit\Framework\TestResult $result)
{
$driver = Stub::makeEmpty('SebastianBergmann\CodeCoverage\Driver\Driver');
$result->setCodeCoverage(new CodeCoverage($driver, new CodeCoverageFilter()));
Filter::setup($this->coverage)
->whiteList($this->filters)
->blackList($this->filters);
$result->setCodeCoverage($this->coverage);
}
protected function mergeToPrint($coverage)
{
Printer::$coverage->merge($coverage);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Codeception;
interface CustomCommandInterface
{
/**
* returns the name of the command
*
* @return string
*/
public static function getCommandName();
}
@@ -0,0 +1,28 @@
<?php
namespace Codeception\Event;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface as ContractsEventDispatcherInterface;
trait DispatcherWrapper
{
/**
* Compatibility wrapper for dispatcher change between Symfony 4 and 5
* @param EventDispatcher $dispatcher
* @param string $eventType
* @param Event $eventObject
*/
protected function dispatch(EventDispatcher $dispatcher, $eventType, Event $eventObject)
{
// The `EventDispatcherInterface` of `Symfony\Contracts` is only implemented in Symfony 4.3 or higher
if ($dispatcher instanceof ContractsEventDispatcherInterface) {
//Symfony 4.3 or higher
$dispatcher->dispatch($eventObject, $eventType);
} else {
//Symfony 4.2 or lower
$dispatcher->dispatch($eventType, $eventObject);
}
}
}
@@ -0,0 +1,32 @@
<?php
namespace Codeception\Event;
class FailEvent extends TestEvent
{
/**
* @var \Exception
*/
protected $fail;
/**
* @var int
*/
protected $count;
public function __construct(\PHPUnit\Framework\Test $test, $time, $e, $count = 0)
{
parent::__construct($test, $time);
$this->fail = $e;
$this->count = $count;
}
public function getCount()
{
return $this->count;
}
public function getFail()
{
return $this->fail;
}
}
@@ -0,0 +1,39 @@
<?php
namespace Codeception\Event;
use Symfony\Component\EventDispatcher\Event;
class PrintResultEvent extends Event
{
/**
* @var \PHPUnit\Framework\TestResult
*/
protected $result;
/**
* @var \PHPUnit\Util\Printer
*/
protected $printer;
public function __construct(\PHPUnit\Framework\TestResult $result, \PHPUnit\Util\Printer $printer)
{
$this->result = $result;
$this->printer = $printer;
}
/**
* @return \PHPUnit\Util\Printer
*/
public function getPrinter()
{
return $this->printer;
}
/**
* @return \PHPUnit\Framework\TestResult
*/
public function getResult()
{
return $this->result;
}
}
@@ -0,0 +1,38 @@
<?php
namespace Codeception\Event;
use Codeception\Step;
use Codeception\TestInterface;
use Symfony\Component\EventDispatcher\Event;
class StepEvent extends Event
{
/**
* @var Step
*/
protected $step;
/**
* @var TestInterface
*/
protected $test;
public function __construct(TestInterface $test, Step $step)
{
$this->test = $test;
$this->step = $step;
}
public function getStep()
{
return $this->step;
}
/**
* @return TestInterface
*/
public function getTest()
{
return $this->test;
}
}
@@ -0,0 +1,54 @@
<?php
namespace Codeception\Event;
use Codeception\Suite;
use Symfony\Component\EventDispatcher\Event;
class SuiteEvent extends Event
{
/**
* @var \PHPUnit\Framework\TestSuite
*/
protected $suite;
/**
* @var \PHPUnit\Framework\TestResult
*/
protected $result;
/**
* @var array
*/
protected $settings;
public function __construct(
\PHPUnit\Framework\TestSuite $suite,
\PHPUnit\Framework\TestResult $result = null,
$settings = []
) {
$this->suite = $suite;
$this->result = $result;
$this->settings = $settings;
}
/**
* @return Suite
*/
public function getSuite()
{
return $this->suite;
}
/**
* @return \PHPUnit\Framework\TestResult
*/
public function getResult()
{
return $this->result;
}
public function getSettings()
{
return $this->settings;
}
}
@@ -0,0 +1,39 @@
<?php
namespace Codeception\Event;
use Symfony\Component\EventDispatcher\Event;
class TestEvent extends Event
{
/**
* @var \PHPUnit\Framework\Test
*/
protected $test;
/**
* @var float Time taken
*/
protected $time;
public function __construct(\PHPUnit\Framework\Test $test, $time = 0)
{
$this->test = $test;
$this->time = $time;
}
/**
* @return float
*/
public function getTime()
{
return $this->time;
}
/**
* @return \Codeception\TestInterface
*/
public function getTest()
{
return $this->test;
}
}
@@ -0,0 +1,135 @@
<?php
namespace Codeception;
/**
* Contains all events dispatched by Codeception.
*
* @author tiger-seo <tiger.seo@gmail.com>
*/
final class Events
{
/**
* Private constructor. This class cannot be instantiated.
*/
private function __construct()
{
}
/**
* The <b>MODULE_INIT</b> event occurs before modules are initialized.
*
* The event listener method receives a {@link Codeception\Event\SuiteEvent} instance.
*/
const MODULE_INIT = 'module.init';
/**
* The <b>SUITE_INIT</b> event occurs when suite is initialized.
* Modules are created and initialized, but Actor class is not loaded.
*
* The event listener method receives a {@link Codeception\Event\SuiteEvent} instance.
*/
const SUITE_INIT = 'suite.init';
/**
* The <b>SUITE_BEFORE</b> event occurs before suite is executed.
*
* The event listener method receives a {@link Codeception\Event\SuiteEvent} instance.
*/
const SUITE_BEFORE = 'suite.before';
/**
* The <b>SUITE_AFTER</b> event occurs after suite has been executed.
*
* The event listener method receives a {@link Codeception\Event\SuiteEvent} instance.
*/
const SUITE_AFTER = 'suite.after';
/**
* The event listener method receives a {@link Codeception\Event\TestEvent} instance.
*/
const TEST_START = 'test.start';
/**
* The event listener method receives a {@link Codeception\Event\TestEvent} instance.
*/
const TEST_BEFORE = 'test.before';
/**
* The event listener method receives a {@link Codeception\Event\StepEvent} instance.
*/
const STEP_BEFORE = 'step.before';
/**
* The event listener method receives a {@link Codeception\Event\StepEvent} instance.
*/
const STEP_AFTER = 'step.after';
/**
* The <b>TEST_FAIL</b> event occurs whenever test has failed.
*
* The event listener method receives a {@link Codeception\Event\FailEvent} instance.
*/
const TEST_FAIL = 'test.fail';
/**
* The <b>TEST_ERROR</b> event occurs whenever test got an error while being executed.
*
* The event listener method receives a {@link Codeception\Event\FailEvent} instance.
*/
const TEST_ERROR = 'test.error';
/**
* The event listener method receives a {@link Codeception\Event\TestEvent} instance.
*/
const TEST_PARSED = 'test.parsed';
/**
* The event listener method receives a {@link Codeception\Event\FailEvent} instance.
*/
const TEST_INCOMPLETE = 'test.incomplete';
/**
* The event listener method receives a {@link Codeception\Event\FailEvent} instance.
*/
const TEST_SKIPPED = 'test.skipped';
/**
* The event listener method receives a {@link Codeception\Event\FailEvent} instance.
*/
const TEST_WARNING = 'test.warning';
/**
* The <b>TEST_USELESS</b> event occurs whenever test does not execute any assertions
* or when it calls expectNotToPerformAssertions and then performs some assertion.
*
* The event listener method receives a {@link \Codeception\Event\FailEvent} instance.
*/
const TEST_USELESS = 'test.useless';
/**
* The event listener method receives a {@link Codeception\Event\TestEvent} instance.
*/
const TEST_SUCCESS = 'test.success';
/**
* The event listener method receives a {@link Codeception\Event\TestEvent} instance.
*/
const TEST_AFTER = 'test.after';
/**
* The event listener method receives a {@link Codeception\Event\TestEvent} instance.
*/
const TEST_END = 'test.end';
/**
* The event listener method receives a {@link Codeception\Event\FailEvent} instance.
*/
const TEST_FAIL_PRINT = 'test.fail.print';
/**
* The event listener method receives a {@link Codeception\Event\PrintResultEvent} instance.
*/
const RESULT_PRINT_AFTER = 'result.print.after';
}
@@ -0,0 +1,111 @@
<?php
namespace Codeception;
use Traversable;
class Example implements \ArrayAccess, \Countable, \IteratorAggregate
{
protected $data;
public function __construct($data)
{
$this->data = $data;
}
#[\ReturnTypeWillChange]
/**
* Whether a offset exists
* @link https://php.net/manual/en/arrayaccess.offsetexists.php
* @param mixed $offset <p>
* An offset to check for.
* </p>
* @return boolean true on success or false on failure.
* </p>
* <p>
* The return value will be casted to boolean if non-boolean was returned.
* @since 5.0.0
*/
public function offsetExists($offset)
{
return array_key_exists($offset, $this->data);
}
#[\ReturnTypeWillChange]
/**
* Offset to retrieve
* @link https://php.net/manual/en/arrayaccess.offsetget.php
* @param mixed $offset <p>
* The offset to retrieve.
* </p>
* @return mixed Can return all value types.
* @since 5.0.0
*/
public function offsetGet($offset)
{
if (!$this->offsetExists($offset)) {
throw new \PHPUnit\Framework\AssertionFailedError("Example $offset doesn't exist");
};
return $this->data[$offset];
}
#[\ReturnTypeWillChange]
/**
* Offset to set
* @link https://php.net/manual/en/arrayaccess.offsetset.php
* @param mixed $offset <p>
* The offset to assign the value to.
* </p>
* @param mixed $value <p>
* The value to set.
* </p>
* @return void
* @since 5.0.0
*/
public function offsetSet($offset, $value)
{
$this->data[$offset] = $value;
}
#[\ReturnTypeWillChange]
/**
* Offset to unset
* @link https://php.net/manual/en/arrayaccess.offsetunset.php
* @param mixed $offset <p>
* The offset to unset.
* </p>
* @return void
* @since 5.0.0
*/
public function offsetUnset($offset)
{
unset($this->data[$offset]);
}
#[\ReturnTypeWillChange]
/**
* Count elements of an object
* @link https://php.net/manual/en/countable.count.php
* @return int The custom count as an integer.
* </p>
* <p>
* The return value is cast to an integer.
* @since 5.1.0
*/
public function count()
{
return count($this->data);
}
#[\ReturnTypeWillChange]
/**
* Retrieve an external iterator
* @link https://php.net/manual/en/iteratoraggregate.getiterator.php
* @return Traversable An instance of an object implementing <b>Iterator</b> or
* <b>Traversable</b>
* @since 5.0.0
*/
public function getIterator()
{
return new \ArrayIterator($this->data);
}
}
@@ -0,0 +1,6 @@
<?php
namespace Codeception\Exception;
class ConditionalAssertionFailed extends \PHPUnit\Framework\AssertionFailedError
{
}
@@ -0,0 +1,6 @@
<?php
namespace Codeception\Exception;
class ConfigurationException extends \Exception
{
}
@@ -0,0 +1,6 @@
<?php
namespace Codeception\Exception;
class ContentNotFound extends \PHPUnit\Framework\AssertionFailedError
{
}
@@ -0,0 +1,15 @@
<?php
namespace Codeception\Exception;
use Codeception\Util\Locator;
class ElementNotFound extends \PHPUnit\Framework\AssertionFailedError
{
public function __construct($selector, $message = null)
{
if (!is_string($selector) || strpos($selector, "'") === false) {
$selector = Locator::humanReadableString($selector);
}
parent::__construct($message . " element with $selector was not found.");
}
}
@@ -0,0 +1,14 @@
<?php
namespace Codeception\Exception;
class ExtensionException extends \Exception
{
public function __construct($extension, $message, \Exception $previous = null)
{
parent::__construct($message, 0, $previous);
if (is_object($extension)) {
$extension = get_class($extension);
}
$this->message = $extension . "\n\n" . $this->message;
}
}
@@ -0,0 +1,6 @@
<?php
namespace Codeception\Exception;
class InjectionException extends \Exception
{
}
@@ -0,0 +1,10 @@
<?php
namespace Codeception\Exception;
class MalformedLocatorException extends TestRuntimeException
{
public function __construct($locator, $type = "CSS or XPath")
{
parent::__construct(ucfirst($type) . " locator is malformed: $locator");
}
}
@@ -0,0 +1,15 @@
<?php
namespace Codeception\Exception;
class ModuleConfigException extends \Exception
{
public function __construct($module, $message, \Exception $previous = null)
{
if (is_object($module)) {
$module = get_class($module);
}
$module = str_replace('Codeception\Module\\', '', ltrim($module, '\\'));
parent::__construct($message, 0, $previous);
$this->message = $module . " module is not configured!\n \n" . $this->message;
}
}
@@ -0,0 +1,23 @@
<?php
namespace Codeception\Exception;
class ModuleConflictException extends \Exception
{
public function __construct($module, $conflicted, $additional = '')
{
if (is_object($module)) {
$module = get_class($module);
}
if (is_object($conflicted)) {
$conflicted = get_class($conflicted);
}
$module = ltrim(str_replace('Codeception\Module\\', '', $module), '\\');
$conflicted = ltrim(str_replace('Codeception\Module\\', '', $conflicted), '\\');
$this->message = "$module module conflicts with $conflicted\n\n--\n"
. "This usually happens when you enable two modules with the same actions but with different backends.\n"
. "For instance, you can't run PhpBrowser, WebDriver, Laravel5 modules in one suite,\n"
. "as they implement similar methods but use different drivers to execute them.\n"
. "You can load a part of module (like: ORM) to avoid conflict.\n"
. $additional;
}
}
@@ -0,0 +1,18 @@
<?php
namespace Codeception\Exception;
class ModuleException extends \Exception
{
protected $module;
public function __construct($module, $message)
{
if (is_object($module)) {
$module = get_class($module);
}
$module = ltrim(str_replace('Codeception\Module\\', '', $module), '\\');
$this->module = $module;
parent::__construct($message);
$this->message = "$module: {$this->message}";
}
}
@@ -0,0 +1,15 @@
<?php
namespace Codeception\Exception;
class ModuleRequireException extends \Exception
{
public function __construct($module, $message)
{
if (is_object($module)) {
$module = get_class($module);
}
$module = str_replace('Codeception\\Module\\', '', ltrim($module, '\\'));
parent::__construct($message);
$this->message = "[$module] module requirements not met --\n \n" . $this->message;
}
}
@@ -0,0 +1,6 @@
<?php
namespace Codeception\Exception;
class ParseException extends \Exception
{
}
@@ -0,0 +1,11 @@
<?php
namespace Codeception\Exception;
class RemoteException extends \Exception
{
public function __construct($message)
{
parent::__construct($message);
$this->message = "Remote Application Error:\n" . $this->message;
}
}
@@ -0,0 +1,17 @@
<?php
namespace Codeception\Exception;
class TestParseException extends \Exception
{
public function __construct($fileName, $errors = null, $line = null)
{
if ($line) {
$this->message = "Couldn't parse test '$fileName' on line $line";
} else {
$this->message = "Couldn't parse test '$fileName'";
}
if ($errors) {
$this->message .= "\n$errors";
}
}
}
@@ -0,0 +1,6 @@
<?php
namespace Codeception\Exception;
class TestRuntimeException extends \RuntimeException
{
}
@@ -0,0 +1,134 @@
<?php
namespace Codeception;
use Codeception\Configuration as Config;
use Codeception\Event\SuiteEvent;
use Codeception\Exception\ModuleRequireException;
use Codeception\Lib\Console\Output;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* A base class for all Codeception Extensions and GroupObjects
*
* Available Properties:
*
* * config: current extension configuration
* * options: passed running options
*
*/
abstract class Extension implements EventSubscriberInterface
{
protected $config = [];
protected $options;
protected $output;
protected $globalConfig;
private $modules = [];
public function __construct($config, $options)
{
$this->config = array_merge($this->config, $config);
$this->options = $options;
$this->output = new Output($options);
$this->_initialize();
}
public static function getSubscribedEvents()
{
if (!isset(static::$events)) {
return [Events::SUITE_INIT => 'receiveModuleContainer'];
}
if (isset(static::$events[Events::SUITE_INIT])) {
if (!is_array(static::$events[Events::SUITE_INIT])) {
static::$events[Events::SUITE_INIT] = [[static::$events[Events::SUITE_INIT]]];
}
static::$events[Events::SUITE_INIT][] = ['receiveModuleContainer'];
} else {
static::$events[Events::SUITE_INIT] = 'receiveModuleContainer';
}
return static::$events;
}
public function receiveModuleContainer(SuiteEvent $e)
{
$this->modules = $e->getSuite()->getModules();
}
/**
* Pass config variables that should be injected into global config.
*
* @param array $config
*/
public function _reconfigure($config = [])
{
if (is_array($config)) {
Config::append($config);
}
}
/**
* You can do all preparations here. No need to override constructor.
* Also you can skip calling `_reconfigure` if you don't need to.
*/
public function _initialize()
{
$this->_reconfigure(); // hook for BC only.
}
protected function write($message)
{
if (!$this->options['silent']) {
$this->output->write($message);
}
}
protected function writeln($message)
{
if (!$this->options['silent']) {
$this->output->writeln($message);
}
}
public function hasModule($name)
{
return isset($this->modules[$name]);
}
public function getCurrentModuleNames()
{
return array_keys($this->modules);
}
public function getModule($name)
{
if (!$this->hasModule($name)) {
throw new ModuleRequireException($name, "module is not enabled");
}
return $this->modules[$name];
}
public function getTestsDir()
{
return Config::testsDir();
}
public function getLogDir()
{
return Config::outputDir();
}
public function getDataDir()
{
return Config::dataDir();
}
public function getRootDir()
{
return Config::projectDir();
}
public function getGlobalConfig()
{
return Config::config();
}
}
@@ -0,0 +1,30 @@
<?php
namespace Codeception;
use Codeception\Event\TestEvent;
abstract class GroupObject extends Extension
{
public static $group;
public function _before(TestEvent $e)
{
}
public function _after(TestEvent $e)
{
}
public static function getSubscribedEvents()
{
$inheritedEvents = parent::getSubscribedEvents();
$events = [];
if (static::$group) {
$events = [
Events::TEST_BEFORE . '.' . static::$group => '_before',
Events::TEST_AFTER . '.' . static::$group => '_after',
];
}
return array_merge($events, $inheritedEvents);
}
}
@@ -0,0 +1,346 @@
<?php
namespace Codeception;
use Codeception\Command\Shared\FileSystem;
use Codeception\Command\Shared\Style;
use Codeception\Lib\ModuleContainer;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
/**
* Codeception templates allow creating a customized setup and configuration for your project.
* An abstract class for installation template. Each init template should extend it and implement a `setup` method.
* Use it to build a custom setup class which can be started with `codecept init` command.
*
*
* ```php
* <?php
* namespace Codeception\Template; // it is important to use this namespace so codecept init could locate this template
* class CustomInstall extends \Codeception\InitTemplate
* {
* public function setup()
* {
* // implement this
* }
* }
* ```
* This class provides various helper methods for building customized setup
*/
abstract class InitTemplate
{
use FileSystem;
use Style;
const GIT_IGNORE = '.gitignore';
/**
* @var string
*/
protected $namespace = '';
/**
* @var string
*/
protected $actorSuffix = 'Tester';
/**
* @var string
*/
protected $workDir = '.';
/**
* @var InputInterface
*/
protected $input;
/**
* @var OutputInterface
*/
protected $output;
public function __construct(InputInterface $input, OutputInterface $output)
{
$this->input = $input;
$this->addStyles($output);
$this->output = $output;
}
/**
* Change the directory where Codeception should be installed.
*/
public function initDir($workDir)
{
$this->checkInstalled($workDir);
$this->sayInfo("Initializing Codeception in $workDir");
$this->createDirectoryFor($workDir);
chdir($workDir);
$this->workDir = $workDir;
}
/**
* Override this class to create customized setup.
* @return mixed
*/
abstract public function setup();
/**
* ```php
* <?php
* // propose firefox as default browser
* $this->ask('select the browser of your choice', 'firefox');
*
* // propose firefox or chrome possible options
* $this->ask('select the browser of your choice', ['firefox', 'chrome']);
*
* // ask true/false question
* $this->ask('do you want to proceed (y/n)', true);
* ```
*
* @param string $question
* @param mixed $answer
* @return mixed|string
*/
protected function ask($question, $answer = null)
{
$question = "? $question";
$dialog = new QuestionHelper();
if (is_array($answer)) {
$question .= " <info>(" . $answer[0] . ")</info> ";
return $dialog->ask($this->input, $this->output, new ChoiceQuestion($question, $answer, 0));
}
if (is_bool($answer)) {
$question .= " (y/n) ";
return $dialog->ask($this->input, $this->output, new ConfirmationQuestion($question, $answer));
}
if ($answer) {
$question .= " <info>($answer)</info>";
}
return $dialog->ask($this->input, $this->output, new Question("$question ", $answer));
}
/**
* Print a message to console.
*
* ```php
* <?php
* $this->say('Welcome to Setup');
* ```
*
*
* @param string $message
*/
protected function say($message = '')
{
$this->output->writeln($message);
}
/**
* Print a successful message
* @param string $message
*/
protected function saySuccess($message)
{
$this->say("<notice> $message </notice>");
}
/**
* Print error message
* @param string $message
*/
protected function sayError($message)
{
$this->say("<error> $message </error>");
}
/**
* Print warning message
* @param $message
*/
protected function sayWarning($message)
{
$this->say("<warning> $message </warning>");
}
/**
* Print info message
* @param string $message
*/
protected function sayInfo($message)
{
$this->say("<debug> $message</debug>");
}
/**
* Create a helper class inside a directory
*
* @param $name
* @param $directory
*/
protected function createHelper($name, $directory)
{
$file = $this->createDirectoryFor(
$dir = $directory . DIRECTORY_SEPARATOR . "Helper",
"$name.php"
) . "$name.php";
$gen = new Lib\Generator\Helper($name, $this->namespace);
// generate helper
$this->createFile(
$file,
$gen->produce()
);
require_once $file;
$this->sayInfo("$name helper has been created in $dir");
}
/**
* Create an empty directory and add a placeholder file into it
* @param $dir
*/
protected function createEmptyDirectory($dir)
{
$this->createDirectoryFor($dir);
$this->createFile($dir . DIRECTORY_SEPARATOR . '.gitkeep', '');
}
protected function gitIgnore($path)
{
if (file_exists(self::GIT_IGNORE)) {
file_put_contents($path . DIRECTORY_SEPARATOR . self::GIT_IGNORE, "*\n!" . self::GIT_IGNORE);
}
}
protected function checkInstalled($dir = '.')
{
if (file_exists($dir . DIRECTORY_SEPARATOR . 'codeception.yml') || file_exists($dir . DIRECTORY_SEPARATOR . 'codeception.dist.yml')) {
throw new \Exception("Codeception is already installed in this directory");
}
}
/**
* Create an Actor class and generate actions for it.
* Requires a suite config as array in 3rd parameter.
*
* @param $name
* @param $directory
* @param $suiteConfig
*/
protected function createActor($name, $directory, $suiteConfig)
{
$file = $this->createDirectoryFor(
$directory,
$name
) . $this->getShortClassName($name);
$file .= '.php';
$suiteConfig['namespace'] = $this->namespace;
$config = Configuration::mergeConfigs(Configuration::$defaultSuiteSettings, $suiteConfig);
$actorGenerator = new Lib\Generator\Actor($config);
$content = $actorGenerator->produce();
$this->createFile($file, $content);
$this->sayInfo("$name actor has been created in $directory");
$actionsGenerator = new Lib\Generator\Actions($config);
$content = $actionsGenerator->produce();
$generatedDir = $directory . DIRECTORY_SEPARATOR . '_generated';
$this->createDirectoryFor($generatedDir, 'Actions.php');
$this->createFile($generatedDir . DIRECTORY_SEPARATOR . $actorGenerator->getActorName() . 'Actions.php', $content);
$this->sayInfo("Actions have been loaded");
}
protected function addModulesToComposer($modules)
{
$packages = ModuleContainer::$packages;
$section = null;
if (!file_exists('composer.json')) {
$this->say('');
$this->sayWarning('Can\'t locate composer.json, please add following packages into "require-dev" section of composer.json:');
$this->say('');
foreach (array_unique($modules) as $module) {
if (!isset($packages[$module])) {
continue;
}
$package = $packages[$module];
$this->say(sprintf('"%s": "%s"', $package, "^1.0.0"));
$composer[$section][$package] = "^1.0.0";
}
$this->say('');
return;
}
$composer = json_decode(file_get_contents('composer.json'), true);
if ($composer === null) {
throw new \Exception("Invalid composer.json file. JSON can't be decoded");
}
$section = null;
if (isset($composer['require'])) {
if (isset($composer['require']['codeception/codeception'])) {
$section = 'require';
}
}
if (isset($composer['require-dev'])) {
if (isset($composer['require-dev']['codeception/codeception'])) {
$section = 'require-dev';
}
}
if (!$section) {
$section = 'require';
}
$packageCounter = 0;
foreach (array_unique($modules) as $module) {
if (!isset($packages[$module])) {
continue;
}
$package = $packages[$module];
if (isset($composer[$section][$package])) {
continue;
}
$this->sayInfo("Adding $package for $module to composer.json");
$composer[$section][$package] = "^1.0.0";
$packageCounter++;
}
file_put_contents('composer.json', json_encode($composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
if ($packageCounter) {
$this->say("$packageCounter new packages added to $section");
}
if ($packageCounter && $this->ask('composer.json updated. Do you want to run "composer update"?', true)) {
$this->sayInfo('Running composer update');
exec('composer update', $output, $status);
if ($status !== 0) {
$this->sayInfo('Composer installation failed. Please check composer.json and try to run "composer update" manually');
return;
}
if (!empty($composer['config']['vendor_dir'])) {
$this->updateComposerClassMap($composer['config']['vendor_dir']);
} else {
$this->updateComposerClassMap();
}
}
return $packageCounter;
}
private function updateComposerClassMap($vendorDir = 'vendor')
{
$loader = require $vendorDir . '/autoload.php';
$classMap = require $vendorDir . '/composer/autoload_classmap.php';
$loader->addClassMap($classMap);
$map = require $vendorDir . '/composer/autoload_psr4.php';
foreach ($map as $namespace => $path) {
$loader->setPsr4($namespace, $path);
}
}
}
@@ -0,0 +1,47 @@
<?php
namespace Codeception\Lib\Actor\Shared;
trait Comment
{
/**
* @return \Codeception\Scenario
*/
abstract protected function getScenario();
public function expectTo($prediction)
{
return $this->comment('I expect to ' . $prediction);
}
public function expect($prediction)
{
return $this->comment('I expect ' . $prediction);
}
public function amGoingTo($argumentation)
{
return $this->comment('I am going to ' . $argumentation);
}
public function am($role)
{
$role = trim($role);
if (stripos('aeiou', $role[0]) !== false) {
return $this->comment('As an ' . $role);
}
return $this->comment('As a ' . $role);
}
public function lookForwardTo($achieveValue)
{
return $this->comment('So that I ' . $achieveValue);
}
public function comment($description)
{
$this->getScenario()->comment($description);
return $this;
}
}
@@ -0,0 +1,29 @@
<?php
namespace Codeception\Lib\Actor\Shared;
use Codeception\Lib\Friend as LibFriend;
use Codeception\Scenario;
trait Friend
{
protected $friends = [];
/**
* @return Scenario
*/
abstract protected function getScenario();
/**
* @param $name
* @param $actorClass
* @return \Codeception\Lib\Friend
*/
public function haveFriend($name, $actorClass = null)
{
if (!isset($this->friends[$name])) {
$actor = $actorClass === null ? $this : new $actorClass($this->getScenario());
$this->friends[$name] = new LibFriend($name, $actor, $this->getScenario()->current('modules'));
}
return $this->friends[$name];
}
}
@@ -0,0 +1,136 @@
<?php
namespace Codeception\Lib\Actor\Shared;
use Codeception\Lib\Console\ReplHistory;
use Symfony\Component\Console\Output\ConsoleOutput;
trait Pause
{
public function pause()
{
if (!\Codeception\Util\Debug::isEnabled()) {
return;
}
if (!class_exists('Hoa\Console\Readline\Readline')) {
throw new \Exception('Hoa Console is not installed. Please add `hoa/console` to composer.json');
}
$autoStash = false;
$I = $this;
$output = new ConsoleOutput();
$readline = new \Hoa\Console\Readline\Readline();
$readline->setAutocompleter(
new \Hoa\Console\Readline\Autocompleter\Word(get_class_methods($this))
);
$stashFn = function (\Hoa\Console\Readline\Readline $self, $isManual = true) {
$lastCommand = $self->previousHistory();
\Hoa\Console\Cursor::clear('↔');
if (strlen($lastCommand) > 0) {
ReplHistory::getInstance()->add("\$I->{$lastCommand};");
codecept_debug("Command stashed: \$I->{$lastCommand};");
} else {
codecept_debug("Nothing to stash.");
}
if ($isManual) {
\Hoa\Console\Console::getOutput()->writeAll($self->getPrefix() . $self->getLine());
}
$self->nextHistory();
return \Hoa\Console\Readline\Readline::STATE_CONTINUE;
};
$clearStashFn = function (\Hoa\Console\Readline\Readline $self) {
ReplHistory::getInstance()->clear();
\Hoa\Console\Cursor::clear('↔');
codecept_debug("Stash cleared.");
\Hoa\Console\Console::getOutput()->writeAll($self->getPrefix() . $self->getLine());
return \Hoa\Console\Readline\Readline::STATE_CONTINUE;
};
$viewStashedFn = function (\Hoa\Console\Readline\Readline $self) use ($output) {
\Hoa\Console\Cursor::clear('↔');
$stashedCommands = ReplHistory::getInstance()->getAll();
if (!empty($stashedCommands)) {
$output->writeln("\n<comment>Stashed commands:</comment>");
codecept_debug(implode("\n", $stashedCommands) . "\n");
} else {
codecept_debug("No commands stashed.");
}
\Hoa\Console\Console::getOutput()->writeAll($self->getPrefix() . $self->getLine());
return \Hoa\Console\Readline\Readline::STATE_CONTINUE;
};
$toggleAutoStashFn = function (\Hoa\Console\Readline\Readline $self) use (&$autoStash) {
\Hoa\Console\Cursor::clear('↔');
$autoStash = !$autoStash;
codecept_debug("Autostash " . ($autoStash ? 'enabled' : 'disabled') . '.');
\Hoa\Console\Console::getOutput()->writeAll($self->getPrefix() . $self->getLine());
return \Hoa\Console\Readline\Readline::STATE_CONTINUE;
};
$tput = \Hoa\Console\Console::getTput();
$readline->addMapping($tput->get('key_f5'), $stashFn);
$readline->addMapping($tput->get('key_f6'), $toggleAutoStashFn);
$readline->addMapping($tput->get('key_f8'), $viewStashedFn);
$readline->addMapping($tput->get('key_f10'), $clearStashFn);
$output->writeln(" <comment>Execution PAUSED, starting interactive shell...</comment>");
$output->writeln(" Type in commands to try them:");
$output->writeln(" - <info>ENTER</info> to continue");
$output->writeln(" - <info>TAB</info> to auto-complete");
$output->writeln(" - <info>F5</info> to stash a command");
$output->writeln(" - <info>F6</info> to toggle auto-stashing of successful commands");
$output->writeln(" - <info>F8</info> to view stashed commands");
$output->writeln(" - <info>F10</info> to clear stashed commands");
$result = '';
do {
$command = $readline->readLine('$I->'); // “> ” is the prefix of the line.
if ($command == 'exit' || $command == '') {
ReplHistory::getInstance()->save();
return;
}
try {
$value = eval("return \$I->$command;");
if ($value) {
$result = $value;
if (!is_object($result)) {
codecept_debug($result);
}
codecept_debug('>> Result saved to $result variable, you can use it in next commands');
}
if ($autoStash) {
call_user_func($stashFn, $readline, false);
}
} catch (\PHPUnit\Framework\AssertionFailedError $fail) {
$output->writeln("<error>fail</error> " . $fail->getMessage());
} catch (\Exception $e) {
$output->writeln("<error>error</error> " . $e->getMessage());
} catch (\Throwable $e) {
$output->writeln("<error>syntax error</error> " . $e->getMessage());
}
} while (true);
}
}
@@ -0,0 +1,24 @@
<?php
namespace Codeception\Lib\Actor\Shared;
trait Retry
{
protected $retryNum = 1;
protected $retryInterval = 100;
/**
* Configure number of retries and initial interval.
* Interval will be doubled on each unsuccessful execution.
*
* Use with \$I->retryXXX() methods;
*
* @param $num
* @param int $interval
*/
public function retry($num, $interval = 200)
{
$this->retryNum = $num;
$this->retryInterval = $interval;
}
}
@@ -0,0 +1,117 @@
<?php
namespace Codeception\Lib\Connector\Shared;
/**
* Converts BrowserKit\Request's request parameters and files into PHP-compatible structure
*
* @see https://bugs.php.net/bug.php?id=25589
* @see https://bugs.php.net/bug.php?id=25589
*
* @package Codeception\Lib\Connector
*/
trait PhpSuperGlobalsConverter
{
/**
* Rearrange files array to be compatible with PHP $_FILES superglobal structure
* @see https://bugs.php.net/bug.php?id=25589
*
* @param array $requestFiles
*
* @return array
*/
protected function remapFiles(array $requestFiles)
{
$files = $this->rearrangeFiles($requestFiles);
return $this->replaceSpaces($files);
}
/**
* Escape high-level variable name with dots, underscores and other "special" chars
* to be compatible with PHP "bug"
* @see https://bugs.php.net/bug.php?id=40000
*
* @param array $parameters
*
* @return array
*/
protected function remapRequestParameters(array $parameters)
{
return $this->replaceSpaces($parameters);
}
private function rearrangeFiles($requestFiles)
{
$files = [];
foreach ($requestFiles as $name => $info) {
if (!is_array($info)) {
continue;
}
/**
* If we have a form with fields like
* ```
* <input type="file" name="foo" />
* <input type="file" name="foo[bar]" />
* ```
* then only array variable will be used while simple variable will be ignored in php $_FILES
* (eg $_FILES = [
* foo => [
* tmp_name => [
* 'bar' => 'asdf'
* ],
* //...
* ]
* ]
* )
* (notice there is no entry for file "foo", only for file "foo[bar]"
* this will check if current element contains inner arrays within it's keys
* so we can ignore element itself and only process inner files
*/
$hasInnerArrays = count(array_filter($info, 'is_array'));
if ($hasInnerArrays || !isset($info['tmp_name'])) {
$inner = $this->remapFiles($info);
foreach ($inner as $innerName => $innerInfo) {
/**
* Convert from ['a' => ['tmp_name' => '/tmp/test.txt'] ]
* to ['tmp_name' => ['a' => '/tmp/test.txt'] ]
*/
$innerInfo = array_map(
function ($v) use ($innerName) {
return [$innerName => $v];
},
$innerInfo
);
if (empty($files[$name])) {
$files[$name] = [];
}
$files[$name] = array_replace_recursive($files[$name], $innerInfo);
}
} else {
$files[$name] = $info;
}
}
return $files;
}
/**
* Replace spaces and dots and other chars in high-level query parameters for
* compatibility with PHP bug (or not a bug)
* @see https://bugs.php.net/bug.php?id=40000
*
* @param array $parameters Array of request parameters to be converted
*
* @return array
*/
private function replaceSpaces($parameters)
{
$qs = http_build_query($parameters, '', '&');
parse_str($qs, $output);
return $output;
}
}
@@ -0,0 +1,37 @@
<?php
namespace Codeception\Lib\Console;
use Symfony\Component\Console\Formatter\OutputFormatter;
class Colorizer
{
/**
* @param string $string
* @return string
*/
public function colorize($string = '')
{
$fp = fopen('php://memory', 'r+');
fwrite($fp, $string);
rewind($fp);
$colorizedMessage = '';
while ($line = fgets($fp)) {
$char = $line[0];
$line = OutputFormatter::escape(trim($line));
switch ($char) {
case '+':
$line = "<info>$line</info>";
break;
case '-':
$line = "<comment>$line</comment>";
break;
}
$colorizedMessage .= $line . "\n";
}
return trim($colorizedMessage);
}
}
@@ -0,0 +1,41 @@
<?php
namespace Codeception\Lib\Console;
use SebastianBergmann\Comparator\ComparisonFailure;
use SebastianBergmann\Diff\Differ;
/**
* DiffFactory
**/
class DiffFactory
{
/**
* @param ComparisonFailure $failure
* @return string|null
*/
public function createDiff(ComparisonFailure $failure)
{
$diff = $this->getDiff($failure->getExpectedAsString(), $failure->getActualAsString());
if (!$diff) {
return null;
}
return $diff;
}
/**
* @param string $expected
* @param string $actual
* @return string
*/
private function getDiff($expected = '', $actual = '')
{
if (!$actual && !$expected) {
return '';
}
$differ = new Differ('');
return $differ->diff($expected, $actual);
}
}
@@ -0,0 +1,122 @@
<?php
namespace Codeception\Lib\Console;
use Symfony\Component\Console\Output\OutputInterface;
class Message
{
protected $output;
protected $message;
public function __construct($message, Output $output = null)
{
$this->message = $message;
$this->output = $output;
}
public function with($param)
{
$args = array_merge([$this->message], func_get_args());
$this->message = call_user_func_array('sprintf', $args);
return $this;
}
public function style($name)
{
$this->message = sprintf('<%s>%s</%s>', $name, $this->message, $name);
return $this;
}
public function width($length, $char = ' ')
{
$message_length = $this->getLength();
if ($message_length < $length) {
$this->message .= str_repeat($char, $length - $message_length);
}
return $this;
}
public function cut($length)
{
$this->message = mb_substr($this->message, 0, $length, 'utf-8');
return $this;
}
public function write($verbose = OutputInterface::VERBOSITY_NORMAL)
{
if ($verbose > $this->output->getVerbosity()) {
return;
}
$this->output->write($this->message);
}
public function writeln($verbose = OutputInterface::VERBOSITY_NORMAL)
{
if ($verbose > $this->output->getVerbosity()) {
return;
}
$this->output->writeln($this->message);
}
public function prepend($string)
{
if ($string instanceof Message) {
$string = $string->getMessage();
}
$this->message = $string . $this->message;
return $this;
}
public function append($string)
{
if ($string instanceof Message) {
$string = $string->getMessage();
}
$this->message .= $string;
return $this;
}
public function apply($func)
{
$this->message = call_user_func($func, $this->message);
return $this;
}
public function center($char)
{
$this->message = $char . $this->message . $char;
return $this;
}
/**
* @return mixed
*/
public function getMessage()
{
return $this->message;
}
public function block($style)
{
$this->message = $this->output->formatHelper->formatBlock($this->message, $style, true);
return $this;
}
public function getLength($includeTags = false)
{
return mb_strwidth($includeTags ? $this->message : strip_tags($this->message), 'utf-8');
}
public static function ucfirst($text)
{
return mb_strtoupper(mb_substr($text, 0, 1, 'utf-8'), 'utf-8') . mb_substr($text, 1, null, 'utf-8');
}
public function __toString()
{
return $this->message;
}
}
@@ -0,0 +1,59 @@
<?php
namespace Codeception\Lib\Console;
use SebastianBergmann\Comparator\ComparisonFailure;
/**
* MessageFactory
**/
class MessageFactory
{
/**
* @var DiffFactory
*/
protected $diffFactory;
/**
* @var Output
*/
private $output;
/**
* @var Colorizer
*/
protected $colorizer;
/**
* MessageFactory constructor.
* @param Output $output
*/
public function __construct(Output $output)
{
$this->output = $output;
$this->diffFactory = new DiffFactory();
$this->colorizer = new Colorizer();
}
/**
* @param string $text
* @return Message
*/
public function message($text = '')
{
return new Message($text, $this->output);
}
/**
* @param ComparisonFailure $failure
* @return string
*/
public function prepareComparisonFailureMessage(ComparisonFailure $failure)
{
$diff = $this->diffFactory->createDiff($failure);
if (!$diff) {
return '';
}
$diff = $this->colorizer->colorize($diff);
return "\n<comment>- Expected</comment> | <info>+ Actual</info>\n$diff";
}
}
@@ -0,0 +1,100 @@
<?php
namespace Codeception\Lib\Console;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
use Symfony\Component\Console\Helper\FormatterHelper;
use Symfony\Component\Console\Output\ConsoleOutput;
class Output extends ConsoleOutput
{
protected $config = [
'colors' => true,
'verbosity' => self::VERBOSITY_NORMAL,
'interactive' => true
];
/**
* @var \Symfony\Component\Console\Helper\FormatterHelper
*/
public $formatHelper;
public $waitForDebugOutput = true;
protected $isInteractive = false;
public function __construct($config)
{
$this->config = array_merge($this->config, $config);
// enable interactive output mode for CLI
$this->isInteractive = $this->config['interactive']
&& isset($_SERVER['TERM'])
&& php_sapi_name() == 'cli'
&& $_SERVER['TERM'] != 'linux';
$formatter = new OutputFormatter($this->config['colors']);
$formatter->setStyle('default', new OutputFormatterStyle());
$formatter->setStyle('bold', new OutputFormatterStyle(null, null, ['bold']));
$formatter->setStyle('focus', new OutputFormatterStyle('magenta', null, ['bold']));
$formatter->setStyle('ok', new OutputFormatterStyle('green', null, ['bold']));
$formatter->setStyle('error', new OutputFormatterStyle('white', 'red', ['bold']));
$formatter->setStyle('fail', new OutputFormatterStyle('red', null, ['bold']));
$formatter->setStyle('pending', new OutputFormatterStyle('yellow', null, ['bold']));
$formatter->setStyle('debug', new OutputFormatterStyle('cyan'));
$formatter->setStyle('comment', new OutputFormatterStyle('yellow'));
$formatter->setStyle('info', new OutputFormatterStyle('green'));
$this->formatHelper = new FormatterHelper();
parent::__construct($this->config['verbosity'], $this->config['colors'], $formatter);
}
public function isInteractive()
{
return $this->isInteractive;
}
protected function clean($message)
{
// clear json serialization
$message = str_replace('\/', '/', $message);
return $message;
}
public function debug($message)
{
$message = print_r($message, true);
$message = str_replace("\n", "\n ", $message);
$message = $this->clean($message);
$message = OutputFormatter::escape($message);
if ($this->waitForDebugOutput) {
$this->writeln('');
$this->waitForDebugOutput = false;
}
$this->writeln("<debug> $message</debug>");
}
public function message($message)
{
$message = call_user_func_array('sprintf', func_get_args());
return new Message($message, $this);
}
public function exception(\Exception $e)
{
$class = get_class($e);
$this->writeln("");
$this->writeln("(![ $class ]!)");
$this->writeln($e->getMessage());
$this->writeln("");
}
public function notification($message)
{
$this->writeln("<comment>$message</comment>");
}
}
@@ -0,0 +1,64 @@
<?php
namespace Codeception\Lib\Console;
class ReplHistory
{
protected $outputFile;
protected $stashedCommands = [];
/**
* @var ReplHistory
*/
protected static $instance;
private function __construct()
{
$this->outputFile = codecept_output_dir('stashed-commands');
if (file_exists($this->outputFile)) {
unlink($this->outputFile);
}
}
/**
* @return ReplHistory
*/
public static function getInstance()
{
if (static::$instance == null) {
static::$instance = new static();
}
return static::$instance;
}
public function add($command)
{
$this->stashedCommands[] = $command;
}
public function getAll()
{
return $this->stashedCommands;
}
public function clear()
{
$this->stashedCommands = [];
}
public function save()
{
if (empty($this->stashedCommands)) {
return;
}
file_put_contents($this->outputFile, implode("\n", $this->stashedCommands) . "\n", FILE_APPEND);
codecept_debug("Stashed commands have been saved to {$this->outputFile}");
$this->clear();
}
}
@@ -0,0 +1,162 @@
<?php
namespace Codeception\Lib;
use Codeception\Exception\InjectionException;
use Codeception\Util\ReflectionHelper;
class Di
{
const DEFAULT_INJECT_METHOD_NAME = '_inject';
protected $container = [];
/**
* @var Di
*/
protected $fallback;
public function __construct($fallback = null)
{
$this->fallback = $fallback;
}
public function get($className)
{
// normalize namespace
$className = ltrim($className, '\\');
return isset($this->container[$className]) ? $this->container[$className] : null;
}
public function set($class)
{
$this->container[get_class($class)] = $class;
}
/**
* @param string $className
* @param array $constructorArgs
* @param string $injectMethodName Method which will be invoked after object creation;
* Resolved dependencies will be passed to it as arguments
* @throws InjectionException
* @return null|object
*/
public function instantiate(
$className,
$constructorArgs = null,
$injectMethodName = self::DEFAULT_INJECT_METHOD_NAME
) {
// normalize namespace
$className = ltrim($className, '\\');
// get class from container
if (isset($this->container[$className])) {
if ($this->container[$className] instanceof $className) {
return $this->container[$className];
}
throw new InjectionException("Failed to resolve cyclic dependencies for class '$className'");
}
// get class from parent container
if ($this->fallback) {
if ($class = $this->fallback->get($className)) {
return $class;
}
}
$this->container[$className] = false; // flag that object is being instantiated
$reflectedClass = new \ReflectionClass($className);
if (!$reflectedClass->isInstantiable()) {
return null;
}
$reflectedConstructor = $reflectedClass->getConstructor();
if (is_null($reflectedConstructor)) {
$object = new $className;
} else {
try {
if (!$constructorArgs) {
$constructorArgs = $this->prepareArgs($reflectedConstructor);
}
} catch (\Exception $e) {
throw new InjectionException("Failed to create instance of '$className'. " . $e->getMessage());
}
$object = $reflectedClass->newInstanceArgs($constructorArgs);
}
if ($injectMethodName) {
$this->injectDependencies($object, $injectMethodName);
}
$this->container[$className] = $object;
return $object;
}
/**
* @param $object
* @param string $injectMethodName Method which will be invoked with resolved dependencies as its arguments
* @throws InjectionException
*/
public function injectDependencies($object, $injectMethodName = self::DEFAULT_INJECT_METHOD_NAME, $defaults = [])
{
if (!is_object($object)) {
return;
}
$reflectedObject = new \ReflectionObject($object);
if (!$reflectedObject->hasMethod($injectMethodName)) {
return;
}
$reflectedMethod = $reflectedObject->getMethod($injectMethodName);
try {
$args = $this->prepareArgs($reflectedMethod, $defaults);
} catch (\Exception $e) {
$msg = $e->getMessage();
if ($e->getPrevious()) { // injection failed because PHP code is invalid. See #3869
$msg .= '; '. $e->getPrevious();
}
throw new InjectionException(
"Failed to inject dependencies in instance of '{$reflectedObject->name}'. $msg"
);
}
if (!$reflectedMethod->isPublic()) {
$reflectedMethod->setAccessible(true);
}
$reflectedMethod->invokeArgs($object, $args);
}
/**
* @param \ReflectionMethod $method
* @param $defaults
* @throws InjectionException
* @return array
*/
protected function prepareArgs(\ReflectionMethod $method, $defaults = [])
{
$args = [];
$parameters = $method->getParameters();
foreach ($parameters as $k => $parameter) {
$dependency = ReflectionHelper::getClassFromParameter($parameter);
if (is_null($dependency)) {
if (!$parameter->isOptional()) {
if (!isset($defaults[$k])) {
throw new InjectionException("Parameter '$parameter->name' must have default value.");
}
$args[] = $defaults[$k];
continue;
}
$args[] = $parameter->getDefaultValue();
} else {
$arg = $this->instantiate($dependency);
if (is_null($arg)) {
throw new InjectionException("Failed to resolve dependency '$dependency'.");
}
$args[] = $arg;
}
}
return $args;
}
}
@@ -0,0 +1,78 @@
<?php
namespace Codeception\Lib;
use Codeception\Actor;
use Codeception\Exception\TestRuntimeException;
class Friend
{
protected $name;
protected $actor;
protected $data = [];
protected $multiSessionModules = [];
public function __construct($name, Actor $actor, $modules = [])
{
$this->name = $name;
$this->actor = $actor;
$this->multiSessionModules = array_filter($modules, function ($m) {
return $m instanceof Interfaces\MultiSession;
});
if (empty($this->multiSessionModules)) {
throw new TestRuntimeException("No multisession modules used. Can't instantiate friend");
}
}
public function does($closure)
{
$currentUserData = [];
foreach ($this->multiSessionModules as $module) {
$name = $module->_getName();
$currentUserData[$name] = $module->_backupSession();
if (empty($this->data[$name])) {
$module->_initializeSession();
$this->data[$name] = $module->_backupSession();
continue;
}
$module->_loadSession($this->data[$name]);
};
$this->actor->comment(strtoupper("{$this->name} does ---"));
$ret = $closure($this->actor);
$this->actor->comment(strtoupper("--- {$this->name} finished"));
foreach ($this->multiSessionModules as $module) {
$name = $module->_getName();
$this->data[$name] = $module->_backupSession();
$module->_loadSession($currentUserData[$name]);
};
return $ret;
}
public function isGoingTo($argumentation)
{
$this->actor->amGoingTo($argumentation);
}
public function expects($prediction)
{
$this->actor->expect($prediction);
}
public function expectsTo($prediction)
{
$this->actor->expectTo($prediction);
}
public function leave()
{
foreach ($this->multiSessionModules as $module) {
if (isset($this->data[$module->_getName()])) {
$module->_closeSession($this->data[$module->_getName()]);
}
}
}
}
@@ -0,0 +1,301 @@
<?php
namespace Codeception\Lib\Generator;
use Codeception\Codecept;
use Codeception\Configuration;
use Codeception\Lib\Di;
use Codeception\Lib\ModuleContainer;
use Codeception\Util\ReflectionHelper;
use Codeception\Util\Template;
class Actions
{
protected $template = <<<EOF
<?php //[STAMP] {{hash}}
namespace {{namespace}}_generated;
// This class was automatically generated by build task
// You should not change it manually as it will be overwritten on next build
// @codingStandardsIgnoreFile
trait {{name}}Actions
{
/**
* @return \Codeception\Scenario
*/
abstract protected function getScenario();
{{methods}}
}
EOF;
protected $methodTemplate = <<<EOF
/**
* [!] Method is generated. Documentation taken from corresponding module.
*
{{doc}}
* @see \{{module}}::{{method}}()
*/
public function {{action}}({{params}}){{return_type}} {
{{return}}\$this->getScenario()->runStep(new \Codeception\Step\{{step}}('{{method}}', func_get_args()));
}
EOF;
protected $name;
protected $settings;
protected $modules = [];
protected $actions;
protected $numMethods = 0;
/**
* @var array GeneratedStep[]
*/
protected $generatedSteps = [];
public function __construct($settings)
{
$this->name = $settings['actor'];
$this->settings = $settings;
$this->di = new Di();
$modules = Configuration::modules($this->settings);
$this->moduleContainer = new ModuleContainer($this->di, $settings);
foreach ($modules as $moduleName) {
$this->moduleContainer->create($moduleName);
}
$this->modules = $this->moduleContainer->all();
$this->actions = $this->moduleContainer->getActions();
$this->generatedSteps = (array) $settings['step_decorators'];
}
public function produce()
{
$namespace = rtrim($this->settings['namespace'], '\\');
$methods = [];
$code = [];
foreach ($this->actions as $action => $moduleName) {
if (in_array($action, $methods)) {
continue;
}
$class = new \ReflectionClass($this->modules[$moduleName]);
$method = $class->getMethod($action);
$code[] = $this->addMethod($method);
$methods[] = $action;
$this->numMethods++;
}
return (new Template($this->template))
->place('namespace', $namespace ? $namespace . '\\' : '')
->place('hash', self::genHash($this->modules, $this->settings))
->place('name', $this->name)
->place('methods', implode("\n\n ", $code))
->produce();
}
protected function addMethod(\ReflectionMethod $refMethod)
{
$class = $refMethod->getDeclaringClass();
$params = $this->getParamsString($refMethod);
$module = $class->getName();
$body = '';
$doc = $this->addDoc($class, $refMethod);
$doc = str_replace('/**', '', $doc);
$doc = trim(str_replace('*/', '', $doc));
if (!$doc) {
$doc = "*";
}
$returnType = $this->createReturnTypeHint($refMethod);
$methodTemplate = (new Template($this->methodTemplate))
->place('module', $module)
->place('method', $refMethod->name)
->place('return_type', $returnType)
->place('return', ($returnType === ': void' || $returnType === ': never') ? '' : 'return ')
->place('params', $params);
if (0 === strpos($refMethod->name, 'see')) {
$type = 'Assertion';
} elseif (0 === strpos($refMethod->name, 'am')) {
$type = 'Condition';
} else {
$type = 'Action';
}
$body .= $methodTemplate
->place('doc', $doc)
->place('action', $refMethod->name)
->place('step', $type)
->produce();
// add auto generated steps
foreach (array_unique($this->generatedSteps) as $generator) {
if (!is_callable([$generator, 'getTemplate'])) {
throw new \Exception("Wrong configuration for generated steps. $generator doesn't implement \Codeception\Step\GeneratedStep interface");
}
$template = call_user_func([$generator, 'getTemplate'], clone $methodTemplate);
if ($template) {
$body .= $template->produce();
}
}
return $body;
}
/**
* @param \ReflectionMethod $refMethod
* @return array
*/
protected function getParamsString(\ReflectionMethod $refMethod)
{
$params = [];
foreach ($refMethod->getParameters() as $param) {
$type = '';
if (PHP_VERSION_ID >= 70000) {
$reflectionType = $param->getType();
if ($reflectionType !== null) {
$type = $this->stringifyType($reflectionType, $refMethod->getDeclaringClass()) . ' ';
}
}
if ($param->isOptional()) {
$params[] = $type . '$' . $param->name . ' = ' . ReflectionHelper::getDefaultValue($param);
} else {
$params[] = $type . '$' . $param->name;
}
}
return implode(', ', $params);
}
/**
* @param \ReflectionClass $class
* @param \ReflectionMethod $refMethod
* @return string
*/
protected function addDoc(\ReflectionClass $class, \ReflectionMethod $refMethod)
{
$doc = $refMethod->getDocComment();
if (!$doc) {
$interfaces = $class->getInterfaces();
foreach ($interfaces as $interface) {
$i = new \ReflectionClass($interface->name);
if ($i->hasMethod($refMethod->name)) {
$doc = $i->getMethod($refMethod->name)->getDocComment();
break;
}
}
}
if (!$doc and $class->getParentClass()) {
$parent = new \ReflectionClass($class->getParentClass()->name);
if ($parent->hasMethod($refMethod->name)) {
$doc = $parent->getMethod($refMethod->name)->getDocComment();
return $doc;
}
return $doc;
}
return $doc;
}
public static function genHash($modules, $settings)
{
$actions = [];
foreach ($modules as $moduleName => $module) {
$actions[$moduleName] = get_class_methods(get_class($module));
}
return md5(Codecept::VERSION . serialize($actions) . serialize($settings['modules']) . implode(',', (array) $settings['step_decorators']));
}
public function getNumMethods()
{
return $this->numMethods;
}
private function createReturnTypeHint(\ReflectionMethod $refMethod)
{
if (PHP_VERSION_ID < 70000) {
return '';
}
$returnType = $refMethod->getReturnType();
if ($returnType === null) {
return '';
}
return ': ' . $this->stringifyType($returnType, $refMethod->getDeclaringClass());
}
/**
* @param \ReflectionType $type
* @return string
*/
private function stringifyType(\ReflectionType $type, \ReflectionClass $moduleClass)
{
if ($type instanceof \ReflectionUnionType) {
return $this->stringifyNamedTypes($type->getTypes(), $moduleClass, '|');
} elseif ($type instanceof \ReflectionIntersectionType) {
return $this->stringifyNamedTypes($type->getTypes(), $moduleClass, '&');
}
if (PHP_VERSION_ID < 70100) {
$returnTypeString = (string)$type;
} else {
$returnTypeString = $type->getName();
}
return sprintf(
'%s%s',
(PHP_VERSION_ID >= 70100 && $type->allowsNull() && $returnTypeString !== 'mixed') ? '?' : '',
self::stringifyNamedType($type, $moduleClass)
);
}
/**
* @param array<\ReflectionNamedType|\ReflectionType> $types
* @param \ReflectionClass $moduleClass
* @param string $separator
* @return string
*/
private function stringifyNamedTypes(array $types, \ReflectionClass $moduleClass, $separator)
{
$strings = [];
foreach ($types as $type) {
$strings []= self::stringifyNamedType($type, $moduleClass);
}
return implode($separator, $strings);
}
/**
* @param \ReflectionNamedType|\ReflectionType $type
* @return string
* @todo param is only \ReflectionNamedType in Codeception 5
*/
public static function stringifyNamedType($type, \ReflectionClass $moduleClass)
{
if (PHP_VERSION_ID < 70100) {
$typeName = (string)$type;
} else {
$typeName = $type->getName();
}
if ($typeName === 'self') {
$typeName = $moduleClass->getName();
} elseif ($typeName === 'parent') {
$typeName = $moduleClass->getParentClass()->getName();
}
return sprintf(
'%s%s',
$type->isBuiltin() ? '' : '\\',
$typeName
);
}
}
@@ -0,0 +1,124 @@
<?php
namespace Codeception\Lib\Generator;
use Codeception\Configuration;
use Codeception\Lib\Di;
use Codeception\Lib\ModuleContainer;
use Codeception\Util\ReflectionHelper;
use Codeception\Util\Template;
class Actor
{
protected $template = <<<EOF
<?php
{{hasNamespace}}
/**
* Inherited Methods
{{inheritedMethods}}
*
* @SuppressWarnings(PHPMD)
*/
class {{actor}} extends \Codeception\Actor
{
use _generated\{{actor}}Actions;
/**
* Define custom actions here
*/
}
EOF;
protected $inheritedMethodTemplate = ' * @method {{return}} {{method}}({{params}})';
protected $settings;
protected $modules;
protected $actions;
public function __construct($settings)
{
$this->settings = $settings;
$this->di = new Di();
$this->moduleContainer = new ModuleContainer($this->di, $settings);
$modules = Configuration::modules($this->settings);
foreach ($modules as $moduleName) {
$this->moduleContainer->create($moduleName);
}
$this->modules = $this->moduleContainer->all();
$this->actions = $this->moduleContainer->getActions();
}
public function produce()
{
$namespace = rtrim($this->settings['namespace'], '\\');
if (!isset($this->settings['actor']) && isset($this->settings['class_name'])) {
$this->settings['actor'] = $this->settings['class_name'];
}
return (new Template($this->template))
->place('hasNamespace', $namespace ? "namespace $namespace;" : '')
->place('actor', $this->settings['actor'])
->place('inheritedMethods', $this->prependAbstractActorDocBlocks())
->produce();
}
protected function prependAbstractActorDocBlocks()
{
$inherited = [];
$class = new \ReflectionClass('\Codeception\\Actor');
$methods = $class->getMethods(\ReflectionMethod::IS_PUBLIC);
foreach ($methods as $method) {
if ($method->name == '__call') {
continue;
} // skipping magic
if ($method->name == '__construct') {
continue;
} // skipping magic
$returnType = 'void';
if ($method->name == 'haveFriend') {
$returnType = '\Codeception\Lib\Friend';
}
$params = $this->getParamsString($method);
$inherited[] = (new Template($this->inheritedMethodTemplate))
->place('method', $method->name)
->place('params', $params)
->place('return', $returnType)
->produce();
}
return implode("\n", $inherited);
}
/**
* @param \ReflectionMethod $refMethod
* @return array
*/
protected function getParamsString(\ReflectionMethod $refMethod)
{
$params = [];
foreach ($refMethod->getParameters() as $param) {
if ($param->isOptional()) {
$params[] = '$' . $param->name . ' = ' . ReflectionHelper::getDefaultValue($param);
} else {
$params[] = '$' . $param->name;
};
}
return implode(', ', $params);
}
public function getActorName()
{
return $this->settings['actor'];
}
public function getModules()
{
return array_keys($this->modules);
}
}
@@ -0,0 +1,41 @@
<?php
namespace Codeception\Lib\Generator;
use Codeception\Exception\ConfigurationException;
use Codeception\Util\Template;
class Cept
{
protected $template = <<<EOF
<?php {{use}}
\$I = new {{actor}}(\$scenario);
\$I->wantTo('perform actions and see result');
EOF;
protected $settings;
public function __construct($settings)
{
$this->settings = $settings;
}
public function produce()
{
$actor = $this->settings['actor'];
if (!$actor) {
throw new ConfigurationException("Cept can't be created for suite without an actor. Add `actor: SomeTester` to suite config");
}
$use = '';
if (! empty($this->settings['namespace'])) {
$namespace = rtrim($this->settings['namespace'], '\\');
$use = "use {$namespace}\\$actor;";
}
return (new Template($this->template))
->place('actor', $actor)
->place('use', $use)
->produce();
}
}

Some files were not shown because too many files have changed in this diff Show More