first commit

This commit is contained in:
2026-01-25 18:18:09 +08:00
commit 509312e604
8136 changed files with 2349298 additions and 0 deletions

View File

@ -0,0 +1,10 @@
/package
/.github
/tests
/vendor
/*.md
/*.yml
/codecept.bat
/nitpick.json
/ruleset.xml
/.php-cs-fixer.dist.php

View File

@ -0,0 +1,17 @@
<?php
$finder = PhpCsFixer\Finder::create()
->in(__DIR__ . '/src')
->in(__DIR__ . '/tests')->notPath('data/Invalid.php')
->in(__DIR__ . '/ext')
->append([__FILE__]);
$config = new PhpCsFixer\Config();
return $config->setRules([
'@PSR12' => true,
'array_syntax' => ['syntax' => 'short'],
'braces' => ['allow_single_line_closure' => true,],
'no_spaces_after_function_name' => true,
'nullable_type_declaration_for_default_null_value' => true,
'single_blank_line_at_eof' => true,
])->setFinder($finder);

21
vendor/codeception/codeception/LICENSE vendored Normal file
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.

46
vendor/codeception/codeception/app.php vendored Normal file
View File

@ -0,0 +1,46 @@
<?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\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();
});

View File

@ -0,0 +1,35 @@
<?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']));
}

7
vendor/codeception/codeception/codecept vendored Executable file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env php
<?php
/**
* Codeception CLI
*/
require __DIR__ . '/app.php';

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" %*

View File

@ -0,0 +1,123 @@
{
"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.ua@gmail.com",
"homepage":"https://codeception.com"
}
],
"minimum-stability": "RC",
"require": {
"php": "^8.1",
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"behat/gherkin": "^4.12",
"codeception/lib-asserts": "^2.0",
"codeception/stub": "^4.1",
"phpunit/phpunit": "^9.5.20 || ^10.0 || ^11.0 || ^12.0",
"phpunit/php-code-coverage": "^9.2 || ^10.0 || ^11.0 || ^12.0",
"phpunit/php-text-template": "^2.0 || ^3.0 || ^4.0 || ^5.0",
"phpunit/php-timer": "^5.0.3 || ^6.0 || ^7.0 || ^8.0",
"sebastian/comparator": "^4.0.5 || ^5.0 || ^6.0 || ^7.0",
"sebastian/diff": "^4.0.3 || ^5.0 || ^6.0 || ^7.0",
"symfony/console": ">=5.4.24 <8.0",
"symfony/css-selector": ">=5.4.24 <8.0",
"symfony/event-dispatcher": ">=5.4.24 <8.0",
"symfony/finder": ">=5.4.24 <8.0",
"symfony/yaml": ">=5.4.24 <8.0",
"symfony/var-dumper": ">=5.4.24 <8.0",
"psy/psysh": "^0.11.2 || ^0.12"
},
"require-dev": {
"ext-simplexml": "*",
"codeception/lib-innerbrowser": "*@dev",
"codeception/lib-web": "*@dev",
"codeception/module-asserts": "*@dev",
"codeception/module-cli": "*@dev",
"codeception/module-db": "*@dev",
"codeception/module-filesystem": "*@dev",
"codeception/module-phpbrowser": "*@dev",
"codeception/util-universalframework": "*@dev",
"symfony/process": ">=5.4.24 <8.0",
"symfony/dotenv": ">=5.4.24 <8.0",
"vlucas/phpdotenv": "^5.1",
"jetbrains/phpstorm-attributes": "^1.0"
},
"conflict": {
"codeception/lib-innerbrowser": "<3.1.3",
"codeception/module-phpbrowser": "<2.5",
"codeception/module-filesystem": "<3.0"
},
"suggest": {
"ext-simplexml": "For loading params from XML files",
"vlucas/phpdotenv": "For loading params from .env files",
"symfony/dotenv": "For loading params from .env files",
"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"
},
"replace": {
"codeception/phpunit-wrapper": "*"
},
"extra": {
"branch-alias": {
"dev-main": "5.2.x-dev"
}
},
"autoload": {
"classmap": [
"src/PHPUnit/TestCase.php"
],
"files": [
"functions.php"
],
"psr-4": {
"Codeception\\": "src/Codeception",
"Codeception\\Extension\\": "ext"
}
},
"autoload-dev": {
"classmap": [
"tests/cli/_steps",
"tests/data/app/data.php",
"tests/data/claypit/tests/_data",
"tests/data/fail_dependencies",
"tests/data/register_command/examples",
"tests/data/DummyClass.php",
"tests/data/DummyOverloadableClass.php",
"tests/data/services/UserModel.php",
"tests/data/services/UserService.php",
"tests/unit/Codeception/Command/BaseCommandRunner.php",
"tests/unit/Codeception/Util/MockAutoload.php",
"tests/unit/Codeception/Util/ReflectionTestClass.php"
]
},
"bin":["codecept"],
"scripts": {
"cs-prod": "phpcs src/ ext/ *.php",
"cs-tests": "phpcs tests/ --standard=tests/phpcs.xml"
},
"scripts-descriptions": {
"cs-prod": "Check production code style",
"cs-tests": "Check test code style"
},
"config": {
"preferred-install": {
"codeception/module-asserts": "source",
"codeception/module-db": "source",
"codeception/module-filesystem": "source",
"codeception/module-phpbrowser": "source",
"codeception/lib-innerbrowser": "source",
"*": "dist"
}
}
}

View File

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace Codeception\Extension;
use Codeception\Event\FailEvent;
use Codeception\Event\PrintResultEvent;
use Codeception\Event\TestEvent;
use Codeception\Events;
use Codeception\Extension;
use Codeception\Subscriber\Console as CodeceptConsole;
/**
* DotReporter provides less verbose output for test execution.
* Like PHPUnit printer it prints dots "." for successful tests 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
{
protected ?CodeceptConsole $standardReporter = null;
protected array $errors = [];
protected array $failures = [];
protected int $width = 10;
protected int $currentPos = 0;
public function _initialize(): void
{
$this->_reconfigure(['settings' => ['silent' => true]]); // turn off printing for everything else
$this->standardReporter = new CodeceptConsole($this->options);
$this->width = $this->standardReporter->detectWidth();
}
/**
* We are listening for events
*
* @var array<string, string>
*/
public static array $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',
Events::RESULT_PRINT_AFTER => 'afterResult',
];
public function beforeSuite(): void
{
$this->output->writeln('');
}
public function success(): void
{
$this->printChar('.');
}
public function fail(FailEvent $event): void
{
$this->printChar('<error>F</error>');
}
public function error(FailEvent $event): void
{
$this->printChar('<error>E</error>');
}
public function skipped(): void
{
$this->printChar('S');
}
protected function printChar(string $char): void
{
if ($this->currentPos >= $this->width) {
$this->output->writeln('');
$this->currentPos = 0;
}
$this->write($char);
++$this->currentPos;
}
public function printFailed(FailEvent $event): void
{
$this->standardReporter->printFail($event);
}
public function afterResult(PrintResultEvent $event): void
{
$this->output->writeln('');
$this->output->writeln('');
$this->standardReporter->afterResult($event);
}
}

View File

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
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;
use function class_exists;
use function function_exists;
use function str_replace;
use function ucfirst;
/**
* 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
{
/**
* @var array<string, string>
*/
public static array $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 ?RotatingFileHandler $logHandler = null;
protected static ?\Monolog\Logger $logger = null;
protected ?string $path = null;
/**
* @var array<string, int>
*/
protected array $config = ['max_files' => 3];
public function _initialize(): void
{
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(): \Monolog\Logger
{
return self::$logger;
}
public function beforeSuite(SuiteEvent $event): void
{
$suiteLogFile = str_replace('\\', '_', $event->getSuite()->getName()) . '.log';
$this->logHandler = new RotatingFileHandler($this->path . $suiteLogFile, $this->config['max_files']);
}
public function beforeTest(TestEvent $event): void
{
self::$logger = new \Monolog\Logger(Descriptor::getTestFullName($event->getTest()));
self::$logger->pushHandler($this->logHandler);
self::$logger->info('------------------------------------');
self::$logger->info('STARTED: ' . ucfirst(Descriptor::getTestAsString($event->getTest())));
}
public function afterTest(TestEvent $event): void
{
}
public function endTest(TestEvent $event): void
{
self::$logger->info('PASSED');
}
public function testFail(FailEvent $event): void
{
self::$logger->alert($event->getFail()->getMessage());
self::$logger->info('# FAILED #');
}
public function testError(FailEvent $event): void
{
self::$logger->alert($event->getFail()->getMessage());
self::$logger->info('# ERROR #');
}
public function testSkipped(FailEvent $event): void
{
self::$logger->info('# Skipped #');
}
public function testIncomplete(FailEvent $event): void
{
self::$logger->info('# Incomplete #');
}
public function beforeStep(StepEvent $event): void
{
self::$logger->info((string) $event->getStep());
}
}

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.

View File

@ -0,0 +1,642 @@
<?php
declare(strict_types=1);
namespace Codeception\Extension;
use Codeception\Event\StepEvent;
use Codeception\Event\TestEvent;
use Codeception\Events;
use Codeception\Exception\ExtensionException;
use Codeception\Extension;
use Codeception\Lib\Interfaces\ScreenshotSaver;
use Codeception\Module;
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;
use DateTime;
use DirectoryIterator;
use Exception;
use Symfony\Contracts\EventDispatcher\Event;
use function array_diff;
use function array_key_exists;
use function array_keys;
use function array_merge;
use function array_unique;
use function basename;
use function codecept_output_dir;
use function codecept_relative_path;
use function dirname;
use function file_put_contents;
use function in_array;
use function is_array;
use function is_dir;
use function mkdir;
use function preg_match;
use function preg_replace;
use function sprintf;
use function str_pad;
use function str_replace;
use function strcasecmp;
use function strlen;
use function substr;
use function trim;
use function ucfirst;
use function uniqid;
/**
* 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)).
* Works 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 Extension
{
protected array $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,
];
protected string $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;
protected string $indicatorTemplate = <<<EOF
<li data-target="#steps" data-slide-to="{{step}}" class="{{isActive}}"></li>
EOF;
protected string $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;
protected string $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;
public static array $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',
];
protected ?Module $webDriverModule = null;
protected ?string $dir = null;
protected array $slides = [];
protected int $stepNum = 0;
protected ?string $seed = null;
protected array $seeds = [];
protected array $recordedTests = [];
protected array $skipRecording = [];
protected array $errorMessages = [];
protected bool $colors = false;
protected bool $ansi = false;
protected array $timeStamps = [];
private ?string $dateFormat = null;
public function beforeSuite(): void
{
$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(): void
{
if (!$this->webDriverModule instanceof Module) {
return;
}
$links = '';
if ($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);
}
}
public function before(TestEvent $event): void
{
if (!$this->webDriverModule instanceof Module) {
return;
}
$this->dir = null;
$this->stepNum = 0;
$this->slides = [];
$this->timeStamps = [];
$this->dir = codecept_output_dir() . "record_{$this->seed}_{$this->getTestName($event)}";
$testPath = codecept_relative_path(Descriptor::getTestFullName($event->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>"
);
}
}
public function cleanup(TestEvent $event): void
{
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 instanceof Module || !$this->dir) {
return;
}
if (!$this->config['delete_successful']) {
$this->persist($event);
return;
}
// deleting successfully executed tests
FileSystem::deleteDir($this->dir);
}
public function persist(TestEvent $event): void
{
if (!$this->webDriverModule instanceof Module) {
return;
}
$indicatorHtml = '';
$slideHtml = '';
$testName = $this->getTestName($event);
$testPath = codecept_relative_path(Descriptor::getTestFullName($event->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) {
$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) {
$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 !== 0 ? '' : 'active')
->produce();
$slideHtml .= (new Template($this->slidesTemplate))
->place('image', $i)
->place('caption', $step->getHtml('#3498db'))
->place('isActive', (int)$i !== 0 ? '' : '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((string) $event->getTest()->getFeature()))
->place('test', Descriptor::getTestSignature($event->getTest()))
->place('carousel_class', $this->config['animate_slides'] ? ' slide' : '')
->produce();
$indexFile = $this->dir . DIRECTORY_SEPARATOR . 'index.html';
$environment = $event->getTest()->getMetadata()->getCurrent('env') ?: '';
$suite = ucfirst(basename(dirname($event->getTest()->getMetadata()->getFilename())));
$testName = basename($event->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' => $event->getTest()->getMetadata()->getName(),
'path' => $testPath,
'status' => $status,
'index' => substr($indexFile, strlen(codecept_output_dir())),
];
}
}
public function afterStep(StepEvent $event): void
{
if (!$this->webDriverModule instanceof Module || $this->dir === null) {
return;
}
if ($event->getStep() instanceof CommentStep) {
return;
}
// only taking the ignore step into consideration if that step has passed
if ($this->isStepIgnored($event) && !$event->getStep()->hasFailed()) {
return;
}
$filename = str_pad((string)$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) {
$testPath = codecept_relative_path(Descriptor::getTestFullName($event->getTest()));
$this->appendErrorMessage(
$testPath,
"⏺ Unable to capture a screenshot for <info>{$testPath}/{$event->getStep()->getAction()}</info>"
);
}
++$this->stepNum;
$this->slides[$filename] = $event->getStep();
$this->timeStamps[$filename] = (new DateTime())->format($this->dateFormat);
}
protected function isStepIgnored(StepEvent $event): bool
{
$configIgnoredSteps = $this->config['ignore_steps'];
$annotationIgnoredSteps = $event->getTest()->getMetadata()->getParam('skipRecording');
/** @var string[] $ignoredSteps */
$ignoredSteps = array_unique(
array_merge(
$configIgnoredSteps,
is_array($annotationIgnoredSteps) ? $annotationIgnoredSteps : []
)
);
foreach ($ignoredSteps as $stepPattern) {
$stepRegexp = '/^' . str_replace('*', '.*?', $stepPattern) . '$/i';
if (preg_match($stepRegexp, $event->getStep()->getAction())) {
return true;
}
if (
$event->getStep()->getMetaStep() !== null &&
preg_match($stepRegexp, $event->getStep()->getMetaStep()->getAction())
) {
return true;
}
}
return false;
}
/**
* @param StepEvent|TestEvent $event
*/
private function getTestName(Event $event): string
{
return basename($event->getTest()->getMetadata()->getFilename()) . '_' . preg_replace('/[^A-Za-z0-9\-\_]/', '_', $event->getTest()->getMetadata()->getName());
}
protected function writeln(iterable|string $messages): void
{
parent::writeln(
$this->ansi
? $messages
: trim(preg_replace('/[ ]{2,}/', ' ', str_replace('⏺', '', $messages)))
);
}
private function appendErrorMessage(string $testPath, string $message): void
{
$this->errorMessages[$testPath] = array_merge(
array_key_exists($testPath, $this->errorMessages) ? $this->errorMessages[$testPath] : [],
[$message]
);
}
}

View File

@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace Codeception\Extension;
use Codeception\Events;
use Codeception\Exception\ExtensionException;
use Codeception\Extension;
use Symfony\Component\Process\Process;
use function array_shift;
use function class_exists;
use function count;
use function is_array;
use function sleep;
/**
* 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 array $config = [];
/**
* @var array<string, string>
*/
protected static array $events = [
Events::SUITE_BEFORE => 'runBefore'
];
private array $processes = [];
public function _initialize(): void
{
if (!class_exists(Process::class)) {
throw new ExtensionException($this, 'symfony/process package is required');
}
}
public function runBefore(): void
{
$this->runProcesses();
$this->processMonitoring();
}
private function runProcesses(): void
{
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);
}
}
private function runProcess(string $command): Process
{
$this->output->debug('[RunBefore] Starting ' . $command);
$process = Process::fromShellCommandline($command, $this->getRootDir());
$process->start();
return $process;
}
/**
* @param string[] $followingCommands
*/
private function addProcessToMonitoring(Process $process, array $followingCommands): void
{
$this->processes[] = [
'instance' => $process,
'following' => $followingCommands
];
}
private function removeProcessFromMonitoring(int $index): void
{
unset($this->processes[$index]);
}
private function processMonitoring(): void
{
while ($this->processes !== []) {
$this->checkProcesses();
sleep(1);
}
}
private function checkProcesses(): void
{
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): void
{
if ($followingCommands !== []) {
$process = $this->runProcess(array_shift($followingCommands));
$this->addProcessToMonitoring($process, $followingCommands);
}
}
private function isRunning(Process $process): bool
{
return $process->isRunning();
}
}

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Codeception\Extension;
use Codeception\Event\PrintResultEvent;
use Codeception\Events;
use Codeception\Extension;
use Codeception\Test\Descriptor;
use function array_key_exists;
use function file_put_contents;
use function implode;
use function is_file;
use function realpath;
use function str_replace;
use function strlen;
use function substr;
use function unlink;
/**
* 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
*
* **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
{
/**
* @var array<string, string>
*/
public static array $events = [
Events::RESULT_PRINT_AFTER => 'saveFailed'
];
/** @var string filename/groupname for failed tests */
protected string $group = 'failed';
public function _initialize(): void
{
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 $event): void
{
$file = $this->getLogDir() . $this->group;
$result = $event->getResult();
if ($result->wasSuccessful()) {
if (is_file($file)) {
unlink($file);
}
return;
}
$output = [];
foreach ($result->failures() as $fail) {
$output[] = $this->localizePath(Descriptor::getTestFullName($fail->getTest()));
}
foreach ($result->errors() as $fail) {
$output[] = $this->localizePath(Descriptor::getTestFullName($fail->getTest()));
}
file_put_contents($file, implode("\n", $output));
}
protected function localizePath(string $path): string
{
$root = realpath($this->getRootDir()) . DIRECTORY_SEPARATOR;
if (str_starts_with($path, $root)) {
return substr($path, strlen($root));
}
return $path;
}
}

View File

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Codeception\Extension;
use BadMethodCallException;
use Codeception\Events;
use Codeception\Exception\ExtensionException;
use Codeception\Extension;
use Symfony\Component\Process\Process;
use function array_reverse;
use function class_exists;
use function is_int;
use function sleep;
/**
* Extension to start and stop processes per suite.
* Can be used to start/stop selenium server, chromedriver, [MailCatcher](https://mailcatcher.me/), etc.
* Each command is executed only once, at the beginning of the test suite. To execute a command before each test, see [Before/After Attributes](https://codeception.com/docs/AdvancedUsage#BeforeAfter-Attributes).
*
* Can be enabled 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
{
/**
* @var array<int|string, mixed>
*/
protected array $config = ['sleep' => 0];
/**
* @var array<string, string>
*/
protected static array $events = [
Events::SUITE_BEFORE => 'runProcess',
Events::SUITE_AFTER => 'stopProcess'
];
/**
* @var Process[]
*/
private array $processes = [];
public function _initialize(): void
{
if (!class_exists(Process::class)) {
throw new ExtensionException($this, 'symfony/process package is required');
}
}
public function runProcess(): void
{
$this->processes = [];
foreach ($this->config as $key => $command) {
if (!$command) {
continue;
}
if (!is_int($key)) {
continue; // configuration options
}
$process = Process::fromShellCommandline($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(): void
{
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 ' . self::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 ' . self::class);
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
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(): void
{
$this->_reconfigure(['settings' => ['silent' => true]]); // turn off printing for everything else
}
/**
* We are listening for events
*
* @var array<string, string>
*/
public static array $events = [
Events::SUITE_BEFORE => 'beforeSuite',
Events::TEST_END => 'after',
Events::TEST_SUCCESS => 'success',
Events::TEST_FAIL => 'fail',
Events::TEST_ERROR => 'error',
];
public function beforeSuite(): void
{
$this->output->writeln('');
}
public function success(): void
{
$this->output->write('[+] ');
}
public function fail(): void
{
$this->output->write('[-] ');
}
public function error(): void
{
$this->output->write('[E] ');
}
// we are printing test status and time taken
public function after(TestEvent $event): void
{
$secondsInput = $event->getTime();
// See https://stackoverflow.com/q/16825240
$milliseconds = (int)($secondsInput * 1000);
$seconds = (int)($milliseconds / 1000);
$time = ($seconds % 60) . (($milliseconds === 0) ? '' : '.' . $milliseconds);
$this->output->write(Descriptor::getTestSignature($event->getTest()));
$this->output->writeln(' (' . $time . 's)');
}
}

View File

@ -0,0 +1,80 @@
<?php
// function not autoloaded in PHP, thus its a good place for them
use Codeception\Extension\Logger;
function codecept_debug($data)
{
\Codeception\Util\Debug::debug($data);
}
/**
* Executes interactive pause in ths place
*
* @param array $vars
* @return void
*/
function codecept_pause(array $vars = []): void
{
\Codeception\Util\Debug::pause($vars);
}
function codecept_root_dir($appendPath = '')
{
return \Codeception\Configuration::projectDir() . $appendPath;
}
function codecept_output_dir($appendPath = '')
{
return \Codeception\Configuration::outputDir() . $appendPath;
}
function codecept_log_dir($appendPath = '')
{
return \Codeception\Configuration::outputDir() . $appendPath;
}
function codecept_data_dir($appendPath = '')
{
return \Codeception\Configuration::dataDir() . $appendPath;
}
function codecept_relative_path($path)
{
return \Codeception\Util\PathResolver::getRelativeDir(
$path,
\Codeception\Configuration::projectDir(),
DIRECTORY_SEPARATOR
);
}
/**
* 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);
}
/**
* 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);
}
function codecept_log(): \Monolog\Logger
{
return Logger::getLogger();
}

View File

@ -0,0 +1,3 @@
<?php
require __DIR__ . '/../app.php';

View File

@ -0,0 +1,9 @@
<?xml version="1.0"?>
<ruleset name="Codeception">
<description>Codeception code standard</description>
<rule ref="PSR12">
<exclude name="Generic.Files.LineLength.TooLong"/>
<exclude name="PSR2.Methods.MethodDeclaration.Underscore"/>
<exclude name="PSR2.Classes.PropertyDeclaration.Underscore"/>
</rule>
</ruleset>

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Codeception;
use Closure;
use Codeception\Lib\Actor\Shared\Comment;
use Codeception\Lib\Actor\Shared\Pause;
use Codeception\Step\Executor;
use RuntimeException;
abstract class Actor
{
use Comment;
use Pause;
public function __construct(protected Scenario $scenario)
{
}
protected function getScenario(): Scenario
{
return $this->scenario;
}
/**
* This method is used by Cept format to add description to test output
*
* It can be used by Cest format too.
* It doesn't do anything when called, but it is parsed by Parser before execution
*
* @see \Codeception\Lib\Parser::parseFeature
*/
public function wantTo(string $text): void
{
}
public function wantToTest(string $text): void
{
}
public function __call(string $method, array $arguments)
{
$class = static::class;
throw new RuntimeException("Call to undefined method {$class}::{$method}");
}
/**
* Lazy-execution given anonymous function
*/
public function execute(Closure $callable): self
{
$this->scenario->addStep(new Executor($callable, []));
$callable();
return $this;
}
}

View File

@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace Codeception;
use Codeception\Exception\ConfigurationException;
use Exception;
use Symfony\Component\Console\Application as BaseApplication;
use Symfony\Component\Console\Input\ArgvInput as SymfonyArgvInput;
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\Output\OutputInterface;
class Application extends BaseApplication
{
protected ?SymfonyArgvInput $coreArguments = null;
/**
* Register commands from config file
*
* extensions:
* commands:
* - Project\Command\MyCustomCommand
*/
public function registerCustomCommands(): void
{
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 $exception, OutputInterface $output): void
{
if (method_exists(BaseApplication::class, 'renderException')) {
//Symfony 5
parent::renderException($exception, $output);
} else {
parent::renderThrowable($exception, $output);
}
}
/**
* Search custom commands and register them.
*
* @throws ConfigurationException
*/
protected function readCustomCommandsFromConfig(): void
{
$this->getCoreArguments(); // Maybe load outside config file
$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 class-string $commandClass A class that implement the `\Codeception\CustomCommandInterface`.
* @throws ConfigurationException
*/
protected function getCustomCommandName(string $commandClass): string
{
if (!class_exists($commandClass)) {
throw new ConfigurationException("Extension: Command class {$commandClass} not found");
}
$interfaces = class_implements($commandClass);
if (!in_array(CustomCommandInterface::class, $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): int
{
if (!$input instanceof InputInterface) {
$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.
*/
protected function getDefaultInputDefinition(): InputDefinition
{
$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
*/
protected function getCoreArguments(): SymfonyArgvInput
{
if ($this->coreArguments instanceof SymfonyArgvInput) {
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 SymfonyArgvInput($argvWithoutConfig);
}
/**
* Pre load Configuration, the config option is use.
*
* @param string $configFile Path to Configuration
* @throws ConfigurationException
*/
protected function preloadConfiguration(string $configFile): void
{
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;
}
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Codeception\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class After
{
public function __construct(string ...$methodNames)
{
}
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Codeception\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
final class AfterClass
{
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Codeception\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Before
{
public function __construct(string ...$methodNames)
{
}
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Codeception\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD)]
final class BeforeClass
{
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Codeception\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class DataProvider
{
public function __construct(string $methodName)
{
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Codeception\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Depends
{
public function __construct(string ...$testNames)
{
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Codeception\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Env
{
public function __construct(string ...$envValues)
{
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Codeception\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Examples
{
public function __construct(mixed ...$values)
{
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Codeception\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Given
{
public function __construct(string $description)
{
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Codeception\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final class Group
{
public function __construct(string ...$groups)
{
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Codeception\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
class Incomplete
{
public function __construct(string $reason = '')
{
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Codeception\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Prepare
{
public function __construct(string ...$methodNames)
{
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Codeception\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
final class Skip
{
public function __construct(string $reason = '')
{
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Codeception\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class Then
{
public function __construct(string $description)
{
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Codeception\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class When
{
public function __construct(string $description)
{
}
}

View File

@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace Codeception;
use Codeception\Coverage\Subscriber\Local;
use Codeception\Coverage\Subscriber\LocalServer;
use Codeception\Coverage\Subscriber\Printer as CoveragePrinter;
use Codeception\Coverage\Subscriber\RemoteServer;
use Codeception\Event\PrintResultEvent;
use Codeception\Exception\ConfigurationException;
use Codeception\Lib\Console\Output;
use Codeception\Lib\Interfaces\ConsolePrinter;
use Codeception\Lib\Notification;
use Codeception\Reporter\HtmlReporter;
use Codeception\Reporter\JUnitReporter;
use Codeception\Reporter\PhpUnitReporter;
use Codeception\Reporter\ReportPrinter;
use Codeception\Subscriber\AutoRebuild;
use Codeception\Subscriber\BeforeAfterTest;
use Codeception\Subscriber\Bootstrap;
use Codeception\Subscriber\Console;
use Codeception\Subscriber\Dependencies;
use Codeception\Subscriber\Deprecation;
use Codeception\Subscriber\ErrorHandler;
use Codeception\Subscriber\ExtensionLoader;
use Codeception\Subscriber\FailFast;
use Codeception\Subscriber\GracefulTermination;
use Codeception\Subscriber\Module;
use Codeception\Subscriber\PrepareTest;
use Symfony\Component\EventDispatcher\EventDispatcher;
class Codecept
{
/**
* @var string
*/
public const VERSION = '5.2.2';
protected ResultAggregator $resultAggregator;
protected EventDispatcher $dispatcher;
protected ExtensionLoader $extensionLoader;
protected array $options = [
'silent' => false,
'debug' => false,
'steps' => false,
'html' => false,
'xml' => false,
'phpunit-xml' => false,
'no-redirect' => true,
'report' => false,
'colors' => false,
'coverage' => false,
'coverage-xml' => false,
'coverage-html' => false,
'coverage-text' => false,
'coverage-crap4j' => false,
'coverage-cobertura' => false,
'coverage-phpunit' => false,
'disable-coverage-php' => false,
'groups' => null,
'excludeGroups' => null,
'filter' => null,
'shard' => null,
'env' => null,
'fail-fast' => 0,
'ansi' => true,
'verbosity' => 1,
'interactive' => true,
'no-rebuild' => false,
'quiet' => false,
];
protected array $config = [];
protected array $extensions = [];
private readonly Output $output;
public function __construct(array $options = [])
{
$this->resultAggregator = new ResultAggregator();
$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->output = new Output($this->options);
$this->registerSubscribers();
}
/**
* Merges given options with default values and current configuration
*
* @throws ConfigurationException
*/
protected function mergeOptions(array $options): array
{
$config = Configuration::config();
$baseOptions = array_merge($this->options, $config['settings']);
return array_merge($baseOptions, $options);
}
public function registerSubscribers(): void
{
// required
$this->dispatcher->addSubscriber(new GracefulTermination($this->resultAggregator));
$this->dispatcher->addSubscriber(new ErrorHandler());
$this->dispatcher->addSubscriber(new Dependencies());
$this->dispatcher->addSubscriber(new Bootstrap());
$this->dispatcher->addSubscriber(new PrepareTest());
$this->dispatcher->addSubscriber(new Module());
$this->dispatcher->addSubscriber(new BeforeAfterTest());
// optional
if (!$this->options['no-rebuild']) {
$this->dispatcher->addSubscriber(new AutoRebuild());
}
if ($this->options['fail-fast'] > 0) {
$this->dispatcher->addSubscriber(new FailFast($this->options['fail-fast'], $this->resultAggregator));
}
if ($this->options['coverage']) {
$this->dispatcher->addSubscriber(new Local($this->options));
$this->dispatcher->addSubscriber(new LocalServer($this->options));
$this->dispatcher->addSubscriber(new RemoteServer($this->options));
$this->dispatcher->addSubscriber(new CoveragePrinter($this->options, $this->output));
}
if ($this->options['report']) {
$this->dispatcher->addSubscriber(new ReportPrinter($this->options));
}
$this->dispatcher->addSubscriber($this->extensionLoader);
$this->extensionLoader->registerGlobalExtensions();
if (!$this->options['silent'] && !$this->isConsolePrinterSubscribed()) {
$this->dispatcher->addSubscriber(new Console($this->options));
}
$this->dispatcher->addSubscriber(new Deprecation($this->options));
$this->registerReporters();
}
private function isConsolePrinterSubscribed(): bool
{
foreach ($this->dispatcher->getListeners() as $listeners) {
foreach ($listeners as $listener) {
if ($listener instanceof ConsolePrinter) {
return true;
}
if (is_array($listener) && $listener[0] instanceof ConsolePrinter) {
return true;
}
}
}
return false;
}
private function registerReporters(): void
{
if (isset($this->config['reporters'])) {
Notification::warning(
"'reporters' option is not supported! Custom reporters must be reimplemented as extensions.",
''
);
}
if ($this->options['html']) {
$this->dispatcher->addSubscriber(
new HtmlReporter($this->options, $this->output)
);
}
if ($this->options['xml']) {
$this->dispatcher->addSubscriber(
new JUnitReporter($this->options, $this->output)
);
}
if ($this->options['phpunit-xml']) {
$this->dispatcher->addSubscriber(
new PhpUnitReporter($this->options, $this->output)
);
}
}
public function run(string $suite, ?string $test = null, ?array $config = null): void
{
ini_set(
'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(',', (string) $envList);
$suiteEnvConfig = $config;
// contains a list of the environments used in this suite configuration env set.
$envConfigs = [];
foreach ($envSet as $currentEnv) {
// 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
if (is_array($config['env'][$currentEnv])) {
$suiteEnvConfig = Configuration::mergeConfigs($suiteEnvConfig, $config['env'][$currentEnv]);
}
$envConfigs[] = $currentEnv;
}
$suiteEnvConfig['current_environment'] = implode(',', $envConfigs);
$suiteToRun = $suite;
if (!empty($envList)) {
$suiteToRun .= ' (' . implode(', ', $envSet) . ')';
}
$this->runSuite($suiteEnvConfig, $suiteToRun, $test);
}
}
public function runSuite(array $settings, string $suite, ?string $test = null): void
{
$settings['shard'] = $this->options['shard'];
$suiteManager = new SuiteManager($this->dispatcher, $suite, $settings, $this->options);
$suiteManager->initialize();
mt_srand($this->options['seed']);
$suiteManager->loadTests($test);
mt_srand();
$suiteManager->run($this->resultAggregator);
}
public static function versionString(): string
{
return 'Codeception PHP Testing Framework v' . self::VERSION;
}
public function printResult(): void
{
$this->dispatcher->dispatch(new PrintResultEvent($this->resultAggregator), Events::RESULT_PRINT_AFTER);
}
public function getResultAggregator(): ResultAggregator
{
return $this->resultAggregator;
}
public function getOptions(): array
{
return $this->options;
}
public function getDispatcher(): EventDispatcher
{
return $this->dispatcher;
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
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(): void
{
$this->setDescription('Creates default test suites and generates all required files')
->addArgument('path', InputArgument::OPTIONAL, 'custom installation dir')
->addOption('namespace', 's', InputOption::VALUE_OPTIONAL, 'Namespace to add for actor classes and helpers')
->addOption('actor', 'a', InputOption::VALUE_OPTIONAL, 'Custom actor instead of Tester')
->addOption('empty', 'e', InputOption::VALUE_NONE, "Don't create standard suites");
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$bootstrap = new BootstrapTemplate($input, $output);
if ($path = $input->getArgument('path')) {
$bootstrap->initDir($path);
}
$bootstrap->setup();
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
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 as SymfonyOutputInterface;
use function implode;
/**
* 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\ConfigTrait;
use Shared\FileSystemTrait;
protected string $inheritedMethodTemplate = ' * @method void %s(%s)';
protected ?SymfonyOutputInterface $output = null;
public function getDescription(): string
{
return 'Generates base classes for all suites';
}
protected function execute(InputInterface $input, SymfonyOutputInterface $output): int
{
$this->output = $output;
$this->buildActorsForConfig();
return Command::SUCCESS;
}
private function buildActor(array $settings): bool
{
$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): bool
{
$actionsGenerator = new ActionsGenerator($settings);
$content = $actionsGenerator->produce();
$this->output->writeln(
sprintf(' -> %sActions.php generated successfully. ', $settings['actor'])
. $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(): void
{
$suites = $this->getSuites();
if ($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(?string $configFile = null): void
{
$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);
}
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
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\ConfigTrait;
protected function configure(): void
{
$this->setDescription('Recursively cleans log and generated code');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->cleanProjectsRecursively($output, Configuration::projectDir());
$output->writeln("Done");
return Command::SUCCESS;
}
private function cleanProjectsRecursively(OutputInterface $output, string $projectDir): void
{
$config = Configuration::config($projectDir);
$logDir = Configuration::outputDir();
$output->writeln(sprintf('<info>Cleaning up output %s...</info>', $logDir));
FileSystem::doEmptyDir($logDir);
$subProjects = $config['include'];
foreach ($subProjects as $subProject) {
$this->cleanProjectsRecursively($output, $projectDir . $subProject);
}
}
}

View File

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Codeception\Command;
use Codeception\Configuration;
use Stecman\Component\Symfony\Console\BashCompletion\Completion as ConsoleCompletion;
use Stecman\Component\Symfony\Console\BashCompletion\Completion\CompletionInterface as ConsoleCompletionInterface;
use Stecman\Component\Symfony\Console\BashCompletion\Completion\ShellPathCompletion;
use Stecman\Component\Symfony\Console\BashCompletion\CompletionCommand;
use Stecman\Component\Symfony\Console\BashCompletion\CompletionHandler;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputDefinition as SymfonyInputDefinition;
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): void
{
// Can't set for all commands, because it wouldn't work well with generate:suite
$suiteCommands = [
'run',
'config:validate',
'console',
'dry-run',
'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',
ConsoleCompletionInterface::TYPE_ARGUMENT,
Configuration::suites()
));
}
$handler->addHandlers([
new ShellPathCompletion(
ConsoleCompletionInterface::ALL_COMMANDS,
'path',
ConsoleCompletionInterface::TYPE_ARGUMENT
),
new ShellPathCompletion(
ConsoleCompletionInterface::ALL_COMMANDS,
'test',
ConsoleCompletionInterface::TYPE_ARGUMENT
),
]);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
if ($input->getOption('generate-hook') && $input->getOption('use-vendor-bin')) {
global $argv;
$argv[0] = 'vendor/bin/' . basename($argv[0]);
}
parent::execute($input, $output);
return Command::SUCCESS;
}
protected function createDefinition(): SymfonyInputDefinition
{
$definition = parent::createDefinition();
$definition->addOption(new InputOption(
'use-vendor-bin',
null,
InputOption::VALUE_NONE,
'Use the vendor bin for autocompletion.'
));
return $definition;
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Codeception\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class CompletionFallback extends Command
{
public function __construct()
{
parent::__construct('_completion');
}
protected function configure(): void
{
$this
->setDescription('BASH completion hook.')
->setHidden(true) // Hide from listing
->setHelp(<<<END
To enable BASH completion, install optional stecman/symfony-console-completion first:
<comment>composer require stecman/symfony-console-completion</comment>
END);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln("Install optional <comment>stecman/symfony-console-completion</comment>");
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
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;
use function codecept_data_dir;
use function codecept_output_dir;
use function codecept_root_dir;
use function implode;
use function preg_replace;
use function print_r;
/**
* 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\ConfigTrait;
use Shared\StyleTrait;
protected function configure(): void
{
$this->setDescription('Validates and prints config to screen')
->addArgument('suite', InputArgument::OPTIONAL, 'To show suite configuration')
->addOption('config', 'c', InputOption::VALUE_OPTIONAL, 'Use custom path for config')
->addOption('override', 'o', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Override config values');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$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 Command::SUCCESS;
}
$output->write("Validating global config... ");
$config = $this->getGlobalConfig();
$output->writeln($input->getOption('override'));
if (!empty($input->getOption('override'))) {
$config = $this->overrideConfig($input->getOption('override'));
}
$output->writeln("Ok");
$suites = Configuration::suites();
$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 Command::SUCCESS;
}
protected function formatOutput($config): ?string
{
$output = print_r($config, true);
return preg_replace('#\[(.*?)] =>#', "<fg=yellow>$1</fg=yellow> =>", $output);
}
}

View File

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Codeception\Command;
use Codeception\Codecept;
use Codeception\Configuration;
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\Suite;
use Codeception\SuiteManager;
use Codeception\Test\Cept;
use Codeception\Util\Debug;
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 function array_keys;
use function file_exists;
use function function_exists;
use function pcntl_signal;
/**
* 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
{
protected ?Cept $test = null;
protected ?Codecept $codecept = null;
protected ?Suite $suite = null;
protected ?OutputInterface $output = null;
/**
* @var string[]
*/
protected array $actions = [];
protected function configure(): void
{
$this->setDescription('Launches interactive test console')
->addArgument('suite', InputArgument::REQUIRED, 'suite to be executed')
->addOption('colors', '', InputOption::VALUE_NONE, 'Use colors in output');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$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);
$eventDispatcher = $this->codecept->getDispatcher();
$suiteManager = new SuiteManager($eventDispatcher, $suiteName, $settings, []);
$suiteManager->initialize();
$this->suite = $suiteManager->getSuite();
$moduleContainer = $suiteManager->getModuleContainer();
$this->actions = array_keys($moduleContainer->getActions());
$this->test = new Cept('', '');
$this->test->getMetadata()->setServices([
'dispatcher' => $eventDispatcher,
'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']) && $config['namespace'] !== '') {
$settings['actor'] = $config['namespace'] . '\\Support\\' . $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, $settings);
$eventDispatcher->dispatch($suiteEvent, Events::SUITE_INIT);
$eventDispatcher->dispatch(new TestEvent($this->test), Events::TEST_PARSED);
$eventDispatcher->dispatch(new TestEvent($this->test), Events::TEST_BEFORE);
if (is_string($settings['bootstrap']) && file_exists($settings['bootstrap'])) {
require $settings['bootstrap'];
}
$I->pause();
$eventDispatcher->dispatch(new TestEvent($this->test), Events::TEST_AFTER);
$eventDispatcher->dispatch(new SuiteEvent($this->suite), Events::SUITE_AFTER);
$output->writeln("<info>Bye-bye!</info>");
return Command::SUCCESS;
}
protected function listenToSignals(): void
{
if (function_exists('pcntl_signal')) {
declare(ticks=1);
pcntl_signal(SIGINT, SIG_IGN);
pcntl_signal(SIGTERM, SIG_IGN);
}
}
}

View File

@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace Codeception\Command;
use Codeception\Configuration;
use Codeception\Event\SuiteEvent;
use Codeception\Event\TestEvent;
use Codeception\Events;
use Codeception\Lib\Generator\Actions;
use Codeception\Lib\ModuleContainer;
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 Exception;
use InvalidArgumentException;
use ReflectionClass;
use ReflectionIntersectionType;
use ReflectionMethod;
use ReflectionNamedType;
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;
use function ini_set;
use function preg_match;
use function str_replace;
/**
* 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 Shared\ConfigTrait;
use Shared\StyleTrait;
protected function configure(): void
{
$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(): string
{
return 'Prints step-by-step scenario-driven test or a feature';
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->addStyles($output);
$suite = (string)$input->getArgument('suite');
$test = $input->getArgument('test');
$config = $this->getGlobalConfig();
ini_set(
'memory_limit',
$config['settings']['memory_limit'] ?? '1024M'
);
if (!Configuration::isEmpty() && !$test && str_starts_with($suite, (string)$config['paths']['tests'])) {
[, $suite, $test] = $this->matchTestFromFilename($suite, $config['paths']['tests']);
}
$settings = $this->getSuiteConfig($suite);
$eventDispatcher = new EventDispatcher();
$eventDispatcher->addSubscriber(new ConsolePrinter([
'colors' => (!$input->hasParameterOption('--no-ansi') xor $input->hasParameterOption('ansi')),
'steps' => true,
'verbosity' => OutputInterface::VERBOSITY_VERBOSE,
]));
$eventDispatcher->addSubscriber(new BootstrapLoader());
$suiteManager = new SuiteManager($eventDispatcher, $suite, $settings, []);
$moduleContainer = $suiteManager->getModuleContainer();
foreach (Configuration::modules($settings) as $module) {
$this->mockModule($module, $moduleContainer);
}
$suiteManager->loadTests($test);
$tests = $suiteManager->getSuite()->getTests();
$eventDispatcher->dispatch(new SuiteEvent($suiteManager->getSuite(), $settings), Events::SUITE_INIT);
$eventDispatcher->dispatch(new SuiteEvent($suiteManager->getSuite(), $settings), Events::SUITE_BEFORE);
foreach ($tests as $test) {
if ($test instanceof Test && $test instanceof ScenarioDriven) {
$this->dryRunTest($output, $eventDispatcher, $test);
}
}
$eventDispatcher->dispatch(new SuiteEvent($suiteManager->getSuite()), Events::SUITE_AFTER);
return 0;
}
protected function matchTestFromFilename($filename, $testsPath): array
{
$filename = str_replace(['//', '\/', '\\'], '/', $filename);
$res = preg_match("#^{$testsPath}/(.*?)/(.*)$#", $filename, $matches);
if (!$res) {
throw new InvalidArgumentException("Test file can't be matched");
}
return $matches;
}
protected function dryRunTest(OutputInterface $output, EventDispatcher $eventDispatcher, Test $test): void
{
$eventDispatcher->dispatch(new TestEvent($test), Events::TEST_START);
$eventDispatcher->dispatch(new TestEvent($test), Events::TEST_BEFORE);
try {
$test->test();
} catch (Exception) {
}
$eventDispatcher->dispatch(new TestEvent($test), Events::TEST_AFTER);
$eventDispatcher->dispatch(new TestEvent($test), Events::TEST_END);
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('');
}
private function mockModule(string $moduleName, ModuleContainer $moduleContainer): void
{
$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): mixed
{
$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 ($returnType->isBuiltin()) {
return $this->getDefaultValueForBuiltinType($returnType);
}
$typeName = Actions::stringifyNamedType($returnType, $class);
return Stub::makeEmpty($typeName);
}
private function getDefaultValueForBuiltinType(ReflectionNamedType $returnType): mixed
{
return match ($returnType->getName()) {
'mixed', 'void' => null,
'string' => '',
'int' => 0,
'float' => 0.0,
'bool' => false,
'array' => [],
'resource' => fopen('data://text/plain;base64,', 'r'),
default => throw new Exception('Unsupported return type ' . $returnType->getName()),
};
}
private function getDefaultValueOfUnionType(ReflectionUnionType $returnType): mixed
{
$unionTypes = $returnType->getTypes();
foreach ($unionTypes as $type) {
if ($type->isBuiltin()) {
return $this->getDefaultValueForBuiltinType($type);
}
}
return Stub::makeEmpty($unionTypes[0]);
}
private function returnDefaultValueForIntersectionType(ReflectionIntersectionType $returnType): mixed
{
$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 ($implements !== []) {
$code .= ' implements ' . implode(', ', $implements);
}
$code .= ' {}';
eval($code);
return Stub::makeEmpty($className);
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
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;
use function file_exists;
/**
* 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\FileSystemTrait;
use Shared\ConfigTrait;
protected function configure(): void
{
$this->setDescription('Generates empty Cest file in suite')
->addArgument('suite', InputArgument::REQUIRED, 'suite where tests will be put')
->addArgument('class', InputArgument::REQUIRED, 'test name');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$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 Command::FAILURE;
}
$cest = new CestGenerator($class, $config);
$res = $this->createFile($filename, $cest->produce());
if (!$res) {
$output->writeln("<error>Test {$filename} already exists</error>");
return Command::FAILURE;
}
$output->writeln("<info>Test was created in {$filename}</info>");
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
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\FileSystemTrait;
use Shared\ConfigTrait;
protected function configure(): void
{
$this->setDescription('Generates empty environment config')
->addArgument('env', InputArgument::REQUIRED, 'Environment name');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$config = $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 = $config['paths']['envs'];
$env = $input->getArgument('env');
$file = $env . '.yml';
$path = $this->createDirectoryFor($relativePath, $file);
$saved = $this->createFile($path . $file, sprintf('# `%s` environment config goes here', $env));
if ($saved) {
$output->writeln(sprintf('<info>%s config was created in %s/%s</info>', $env, $relativePath, $file));
return Command::SUCCESS;
}
$output->writeln(sprintf('<error>File %s/%s already exists</error>', $relativePath, $file));
return Command::FAILURE;
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
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;
use function basename;
use function preg_match;
use function rtrim;
/**
* 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\FileSystemTrait;
use Shared\ConfigTrait;
protected function configure(): void
{
$this->setDescription('Generates empty feature file in suite')
->addArgument('suite', InputArgument::REQUIRED, 'suite to be tested')
->addArgument('feature', InputArgument::REQUIRED, 'feature to be generated')
->addOption('config', 'c', InputOption::VALUE_OPTIONAL, 'Use custom path for config');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$suite = $input->getArgument('suite');
$filename = (string)$input->getArgument('feature');
$config = $this->getSuiteConfig($suite);
$this->createDirectoryFor($config['path'], $filename);
$feature = new Feature(basename($filename));
if (!preg_match('#\.feature$#', $filename)) {
$filename .= '.feature';
}
$fullPath = rtrim((string) $config['path'], DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename;
$res = $this->createFile($fullPath, $feature->produce());
if (!$res) {
$output->writeln("<error>Feature {$filename} already exists</error>");
return Command::FAILURE;
}
$output->writeln("<info>Feature was created in {$fullPath}</info>");
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
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;
use function ucfirst;
/**
* Creates empty GroupObject - extension which handles all group events.
*
* * `codecept g:group Admin`
*/
class GenerateGroup extends Command
{
use Shared\FileSystemTrait;
use Shared\ConfigTrait;
protected function configure(): void
{
$this->setDescription('Generates Group subscriber')
->addArgument('group', InputArgument::REQUIRED, 'Group class name');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$config = $this->getGlobalConfig();
$groupInputArgument = (string)$input->getArgument('group');
$class = ucfirst($groupInputArgument);
$path = $this->createDirectoryFor(Configuration::supportDir() . 'Group' . DIRECTORY_SEPARATOR, $class);
$filename = $path . $class . '.php';
$group = new GroupGenerator($config, $groupInputArgument);
$res = $this->createFile($filename, $group->produce());
if (!$res) {
$output->writeln("<error>Group {$filename} already exists</error>");
return Command::FAILURE;
}
$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 Command::SUCCESS;
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
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;
use function ucfirst;
/**
* Creates empty Helper class.
*
* * `codecept g:helper MyHelper`
* * `codecept g:helper "My\Helper"`
*
*/
class GenerateHelper extends Command
{
use Shared\FileSystemTrait;
use Shared\ConfigTrait;
protected function configure(): void
{
$this->setDescription('Generates a new helper')
->addArgument('name', InputArgument::REQUIRED, 'Helper name');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$name = ucfirst((string)$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($config, $name))->produce());
if ($res) {
$output->writeln("<info>Helper {$filename} created</info>");
return Command::SUCCESS;
}
$output->writeln(sprintf('<error>Error creating helper %s</error>', $filename));
return Command::FAILURE;
}
}

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
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;
use function ucfirst;
/**
* 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\FileSystemTrait;
use Shared\ConfigTrait;
protected function configure(): void
{
$this->setDescription('Generates empty PageObject class')
->addArgument('suite', InputArgument::REQUIRED, 'Either suite name or page object name')
->addArgument('page', InputArgument::OPTIONAL, 'Page name of pageobject to represent');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$suite = (string)$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);
$pageObject = new PageObjectGenerator($conf, ucfirst($suite) . '\\' . $class);
$res = $this->createFile($filename, $pageObject->produce());
if (!$res) {
$output->writeln("<error>PageObject {$filename} already exists</error>");
return Command::FAILURE;
}
$output->writeln("<info>PageObject was created in {$filename}</info>");
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Codeception\Command;
use Codeception\Configuration;
use Codeception\Exception\ConfigurationException;
use Codeception\SuiteManager;
use Codeception\Test\Cest;
use Codeception\Test\Interfaces\Descriptive;
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;
use function basename;
use function file_exists;
use function is_writable;
use function mkdir;
use function preg_replace;
/**
* 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\FileSystemTrait;
use Shared\ConfigTrait;
protected function configure(): void
{
$this->setDescription('Generates text representation for all scenarios')
->addArgument('suite', InputArgument::REQUIRED, 'suite from which texts should be generated')
->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Use specified path as destination instead of default')
->addOption('single-file', '', InputOption::VALUE_NONE, 'Render all scenarios to only one file')
->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Specify output format: html or text (default)', 'text');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$suite = $input->getArgument('suite');
$suiteConf = $this->getSuiteConfig($suite);
$path = $input->getOption('path') ?: Configuration::dataDir() . 'scenarios';
$format = $input->getOption('format');
@mkdir($path, 0777, true);
if (!is_writable($path)) {
throw new ConfigurationException(
"Path {$path} is not writable. Please, set valid permissions for folder to store scenarios."
);
}
$path .= DIRECTORY_SEPARATOR . $suite;
if (!$input->getOption('single-file')) {
@mkdir($path);
}
$suiteManager = new SuiteManager(new EventDispatcher(), $suite, $suiteConf, []);
if ($suiteConf['bootstrap'] && file_exists($suiteConf['path'] . $suiteConf['bootstrap'])) {
require_once $suiteConf['path'] . $suiteConf['bootstrap'];
}
$tests = $this->getTests($suiteManager);
$scenarios = '';
$output->writeln('<comment>This command is deprecated and will be removed in the next major version of Codeception.</comment>');
foreach ($tests as $test) {
if (!$test instanceof ScenarioDriven || !$test instanceof Descriptive) {
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 Command::SUCCESS;
}
protected function decorate(string $text, string $format): string
{
if ($format === 'html') {
return "<html><body>{$text}</body></html>";
}
return $text;
}
protected function getTests($suiteManager)
{
$suiteManager->loadTests();
return $suiteManager->getSuite()->getTests();
}
protected function formatExtension(string $format): string
{
return '.' . ($format === 'html' ? 'html' : 'txt');
}
private function underscore(string $name): string
{
$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);
return preg_replace('#_Cest$#', '', $name);
}
}

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
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;
use function ucfirst;
/**
* 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\FileSystemTrait;
use Shared\ConfigTrait;
protected function configure(): void
{
$this->setDescription('Generates empty Snapshot class')
->addArgument('suite', InputArgument::REQUIRED, 'Suite name or snapshot name)')
->addArgument('snapshot', InputArgument::OPTIONAL, 'Name of snapshot');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$suite = (string)$input->getArgument('suite');
$class = $input->getArgument('snapshot');
if (!$class) {
$class = $suite;
$suite = '';
}
$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);
$snapshot = new SnapshotGenerator($conf, ucfirst($suite) . '\\' . $class);
$res = $this->createFile($filename, $snapshot->produce());
if (!$res) {
$output->writeln("<error>Snapshot {$filename} already exists</error>");
return Command::FAILURE;
}
$output->writeln("<info>Snapshot was created in {$filename}</info>");
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Codeception\Command;
use Codeception\Configuration;
use Codeception\Lib\Generator\StepObject as StepObjectGenerator;
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;
use function ucfirst;
/**
* 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\FileSystemTrait;
use Shared\ConfigTrait;
protected function configure(): void
{
$this->setDescription('Generates empty StepObject class')
->addArgument('suite', InputArgument::REQUIRED, 'Suite for StepObject')
->addArgument('step', InputArgument::REQUIRED, 'StepObject name')
->addOption('silent', '', InputOption::VALUE_NONE, 'Skip verification question');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$suite = (string)$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);
/** @var QuestionHelper $dialog */
$dialog = $this->getHelper('question');
$filename = $path . $class . '.php';
$stepObject = 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) {
$stepObject->createAction($action);
}
} while ($action);
}
$res = $this->createFile($filename, $stepObject->produce());
if (!$res) {
$output->writeln("<error>StepObject {$filename} already exists</error>");
return Command::FAILURE;
}
$output->writeln("<info>StepObject was created in {$filename}</info>");
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Codeception\Command;
use Codeception\Configuration;
use Codeception\Lib\Generator\Actor as ActorGenerator;
use Codeception\Util\Template;
use Exception;
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\Yaml\Yaml;
use function file_exists;
use function preg_match;
use function ucfirst;
/**
* 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\FileSystemTrait;
use Shared\ConfigTrait;
use Shared\StyleTrait;
protected function configure(): void
{
$this->setDescription('Generates new test suite')
->addArgument('suite', InputArgument::REQUIRED, 'suite to be generated')
->addArgument('actor', InputArgument::OPTIONAL, 'name of new actor class');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->addStyles($output);
$suite = ucfirst((string)$input->getArgument('suite'));
$config = $this->getGlobalConfig();
$actor = $input->getArgument('actor') ?: $suite . $config['actor_suffix'];
if ($this->containsInvalidCharacters($suite)) {
$output->writeln("<error>Suite name '{$suite}' contains invalid characters. ([A-Za-z0-9_]).</error>");
return Command::FAILURE;
}
$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
);
}
$yamlSuiteConfigTemplate = <<<EOF
actor: {{actor}}
suite_namespace: {{suite_namespace}}
modules:
# enable helpers as array
enabled: []
EOF;
$yamlSuiteConfig = (new Template($yamlSuiteConfigTemplate))
->place('actor', $actor)
->place('suite_namespace', $config['namespace'] . '\\' . $suite)
->produce();
$this->createFile($dir . $suite . '.suite.yml', $yamlSuiteConfig);
Configuration::append(Yaml::parse($yamlSuiteConfig));
$actorGenerator = new ActorGenerator(Configuration::config());
$content = $actorGenerator->produce();
$file = $this->createDirectoryFor(Configuration::supportDir(), $actor) . $this->getShortClassName($actor) . '.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 Command::SUCCESS;
}
private function containsInvalidCharacters(string $suite): bool
{
return (bool)preg_match('#[^A-Za-z0-9_]#', $suite);
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
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\FileSystemTrait;
use Shared\ConfigTrait;
protected function configure(): void
{
$this->setDescription('Generates empty unit test file in suite')
->addArgument('suite', InputArgument::REQUIRED, 'Suite where tests will be put')
->addArgument('class', InputArgument::REQUIRED, 'Class name');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$suite = $input->getArgument('suite');
$class = $input->getArgument('class');
$config = $this->getSuiteConfig($suite);
$className = $this->getShortClassName($class);
$path = $this->createDirectoryFor($config['path'], $class);
$filename = $path . $this->completeSuffix($className, 'Test');
$test = new TestGenerator($config, $class);
$res = $this->createFile($filename, $test->produce());
if (!$res) {
$output->writeln("<error>Test {$filename} already exists</error>");
return Command::FAILURE;
}
$output->writeln("<info>Test was created in {$filename}</info>");
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Codeception\Command;
use Codeception\Lib\Generator\GherkinSnippets as GherkinSnippetsGenerator;
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 function count;
/**
* 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\ConfigTrait;
use Shared\StyleTrait;
protected function configure(): void
{
$this->setDescription('Fetches empty steps from feature files of suite and prints code snippets for them')
->addArgument('suite', InputArgument::REQUIRED, 'Suite to scan for feature files')
->addArgument('test', InputArgument::OPTIONAL, 'Test to be scanned')
->addOption('config', 'c', InputOption::VALUE_OPTIONAL, 'Use custom path for config');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->addStyles($output);
$suite = $input->getArgument('suite');
$test = $input->getArgument('test');
$config = $this->getSuiteConfig($suite);
$generator = new GherkinSnippetsGenerator($config, $test);
$snippets = $generator->getSnippets();
if ($snippets === []) {
$output->writeln("<notice> All Gherkin steps are defined. Exiting... </notice>");
return Command::SUCCESS;
}
$output->writeln("<comment> Snippets found in: </comment>");
foreach ($generator->getFeatures() 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 Command::SUCCESS;
}
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Codeception\Command;
use Codeception\Test\Loader\Gherkin as GherkinLoader;
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;
use function count;
/**
* Prints all steps from all Gherkin contexts for a specific suite
*
* ```
* codecept gherkin:steps acceptance
* ```
*
*/
class GherkinSteps extends Command
{
use Shared\ConfigTrait;
use Shared\StyleTrait;
protected function configure(): void
{
$this->setDescription('Prints all defined feature steps')
->addArgument('suite', InputArgument::REQUIRED, 'suite to scan for feature files')
->addOption('config', 'c', InputOption::VALUE_OPTIONAL, 'Use custom path for config');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->addStyles($output);
$suite = $input->getArgument('suite');
$config = $this->getSuiteConfig($suite);
$config['describe_steps'] = true;
$loader = new GherkinLoader($config);
$steps = $loader->getSteps();
foreach ($steps as $name => $context) {
$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) {
$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 Command::SUCCESS;
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Codeception\Command;
use Codeception\InitTemplate;
use Exception;
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 function class_exists;
use function ucfirst;
class Init extends Command
{
protected function configure(): void
{
$this->setDescription("Creates test suites by a template")
->addArgument('template', InputArgument::REQUIRED, 'Init template for the setup')
->addOption('path', null, InputOption::VALUE_REQUIRED, 'Change current directory')
->addOption('namespace', null, InputOption::VALUE_OPTIONAL, 'Namespace to add for actor classes and helpers');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$template = (string) $input->getArgument('template');
$className = class_exists($template) ? $template : '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 ($path = $input->getOption('path')) {
$initProcess->initDir($path);
}
$initProcess->setup();
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,727 @@
<?php
declare(strict_types=1);
namespace Codeception\Command;
use Codeception\Codecept;
use Codeception\Configuration;
use Codeception\Exception\ConfigurationException;
use Codeception\Exception\ParseException;
use Exception;
use InvalidArgumentException;
use RuntimeException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException as SymfonyConsoleInvalidArgumentException;
use Symfony\Component\Console\Exception\InvalidOptionException;
use Symfony\Component\Console\Input\ArgvInput;
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 function array_flip;
use function array_intersect_key;
use function array_merge;
use function count;
use function explode;
use function extension_loaded;
use function getcwd;
use function implode;
use function in_array;
use function preg_match;
use function preg_replace;
use function rtrim;
use function sprintf;
use function str_contains;
use function str_replace;
use function str_starts_with;
use function strpos;
use function strtolower;
use function substr;
use function substr_replace;
/**
* 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 MyCest:myTestInIt#1`: run one example or data provider item by number
* * `codecept run acceptance MyCest:myTestInIt#1-3`: run a range of examples or data provider items
* * `codecept run acceptance MyCest:myTestInIt@name.*`: run data provider items with matching names
* * `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
*
* 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
* --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")
* --disable-coverage-php Don't generate CodeCoverage report in raw PHP serialized format
* --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 nth failure (defaults to 1)
* --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\ConfigTrait;
protected ?Codecept $codecept = null;
/**
* @var int Executed suites
*/
protected int $executed = 0;
protected array $options = [];
protected ?OutputInterface $output = null;
/**
* Sets Run arguments
*
* @throws SymfonyConsoleInvalidArgumentException
*/
protected function configure(): void
{
$this->setDescription('Runs the test suites')
->addArgument('suite', InputArgument::OPTIONAL, 'suite to be tested')
->addArgument('test', InputArgument::OPTIONAL, 'test to be run')
->addOption('override', 'o', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Override config values')
->addOption('ext', 'e', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Run with extension enabled')
->addOption('report', '', InputOption::VALUE_NONE, 'Show output in compact style')
->addOption('html', '', InputOption::VALUE_OPTIONAL, 'Generate html with results', 'report.html')
->addOption('xml', '', InputOption::VALUE_OPTIONAL, 'Generate JUnit XML Log', 'report.xml')
->addOption('phpunit-xml', '', InputOption::VALUE_OPTIONAL, 'Generate PhpUnit XML Log', 'phpunit-report.xml')
->addOption('colors', '', InputOption::VALUE_NONE, 'Use colors in output')
->addOption('no-colors', '', InputOption::VALUE_NONE, 'Force no colors in output (useful to override config file)')
->addOption('silent', '', InputOption::VALUE_NONE, 'Only outputs suite names and final results')
->addOption('steps', '', InputOption::VALUE_NONE, 'Show steps in output')
->addOption('debug', 'd', InputOption::VALUE_NONE, 'Show debug and scenario output')
->addOption('shard', '', InputOption::VALUE_REQUIRED, 'Execute subset of tests to run tests on different machine. To split tests on 3 machines to run with shards: 1/3, 2/3, 3/3')
->addOption('filter', '', InputOption::VALUE_REQUIRED, 'Filter tests by name')
->addOption('grep', '', InputOption::VALUE_REQUIRED, 'Filter tests by name (alias to --filter)')
->addOption('bootstrap', '', InputOption::VALUE_OPTIONAL, 'Execute custom PHP script before running tests. Path can be absolute or relative to current working directory', false)
->addOption('no-redirect', '', InputOption::VALUE_NONE, 'Do not redirect to Composer-installed version in vendor/codeception')
->addOption('coverage', '', InputOption::VALUE_OPTIONAL, 'Run with code coverage')
->addOption('coverage-html', '', InputOption::VALUE_OPTIONAL, 'Generate CodeCoverage HTML report in path')
->addOption('coverage-xml', '', InputOption::VALUE_OPTIONAL, 'Generate CodeCoverage XML report in file')
->addOption('coverage-text', '', InputOption::VALUE_OPTIONAL, 'Generate CodeCoverage text report in file')
->addOption('coverage-crap4j', '', InputOption::VALUE_OPTIONAL, 'Generate CodeCoverage report in Crap4J XML format')
->addOption('coverage-cobertura', '', InputOption::VALUE_OPTIONAL, 'Generate CodeCoverage report in Cobertura XML format')
->addOption('coverage-phpunit', '', InputOption::VALUE_OPTIONAL, 'Generate CodeCoverage PHPUnit report in path')
->addOption('disable-coverage-php', '', InputOption::VALUE_NONE, "Don't generate CodeCoverage report in raw PHP serialized format")
->addOption('no-exit', '', InputOption::VALUE_NONE, "Don't finish with exit code")
->addOption('group', 'g', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Groups of tests to be executed')
->addOption('skip', 's', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Skip selected suites')
->addOption('skip-group', 'x', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Skip selected groups')
->addOption('env', '', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Run tests in selected environments.')
->addOption('fail-fast', 'f', InputOption::VALUE_OPTIONAL, 'Stop after nth failure')
->addOption('no-rebuild', '', InputOption::VALUE_NONE, 'Do not rebuild actor classes on start')
->addOption('seed', '', InputOption::VALUE_REQUIRED, 'Define random seed for shuffle setting')
->addOption('no-artifacts', '', InputOption::VALUE_NONE, "Don't report about artifacts");
}
/**
* Executes Run
*
* @throws ConfigurationException|ParseException
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$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://stand-with-ukraine.pp.ua'
);
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',
'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'));
$userOptions['disable-coverage-php'] = (bool) $this->options['disable-coverage-php'];
$userOptions['seed'] = $this->options['seed'] ? (int)$this->options['seed'] : rand();
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['coverage-xml'] || $this->options['coverage-html'] || $this->options['coverage-text'] || $this->options['coverage-crap4j'] || $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
}
// array key will exist if fail-fast option is used
if (array_key_exists('fail-fast', $userOptions)) {
$userOptions['fail-fast'] = (int)$this->options['fail-fast'] ?: 1;
}
$suite = (string)$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 (str_starts_with($suite, (string)$include)) {
// Use include config
$config = Configuration::config($projectDir . $include);
$config = $this->addRuntimeOptionsToCurrentConfig($config);
if (!empty($this->options['override'])) {
$config = $this->overrideConfig($this->options['override']);
}
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 {
[, $suite, $test] = $this->matchTestFromFilename($suite, $testsPath);
$isIncludeTest = true;
} catch (InvalidArgumentException) {
// Incorrect include match, continue trying to find one
continue;
}
} else {
$result = $this->matchSingleTest($suite, $config);
if ($result) {
[, $suite, $test] = $result;
}
}
}
// Restore main config
if (!$isIncludeTest) {
$config = $this->addRuntimeOptionsToCurrentConfig(
Configuration::config($projectDir)
);
}
} elseif (!empty($suite)) {
$result = $this->matchSingleTest($suite, $config);
if ($result) {
[, $suite, $test] = $result;
}
}
}
$filter = $input->getOption('filter') ?? $input->getOption('grep') ?? null;
if ($test) {
$userOptions['filter'] = $this->matchFilteredTestName($test);
} elseif (
$suite
&& !$this->isWildcardSuiteName($suite)
&& !$this->isSuiteInMultiApplication($suite)
) {
$userOptions['filter'] = $this->matchFilteredTestName($suite);
}
if (isset($userOptions['filter']) && $filter) {
throw new InvalidOptionException("--filter and --grep can't be used with a test name");
} elseif ($filter) {
$userOptions['filter'] = $filter;
}
if ($this->options['shard']) {
$this->output->writeln(
"[Shard {$userOptions['shard']}] <info>Running subset of tests</info>"
);
}
if (!$this->options['silent'] && $config['settings']['shuffle']) {
$this->output->writeln(
"[Seed] <info>" . $userOptions['seed'] . "</info>"
);
}
$this->codecept = new Codecept($userOptions);
if ($suite && $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 Command::INVALID;
}
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 ($this->options['shard']) {
$this->output->writeln(
"[Shard {$userOptions['shard']}] <info>Merge this result with other shards to see the complete report</info>"
);
}
if (!$input->getOption('no-exit') && !$this->codecept->getResultAggregator()->wasSuccessfulIgnoringWarnings()) {
exit(1);
}
return Command::SUCCESS;
}
protected function matchSingleTest(string $suite, array $config): ?array
{
// 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 (str_starts_with($suite, (string)$config['paths']['tests'])) {
return $this->matchTestFromFilename($suite, $config['paths']['tests']);
}
// Run single test from working directory
$realTestDir = (string)realpath(Configuration::testsDir());
$cwd = (string)getcwd();
if (str_starts_with($realTestDir, $cwd)) {
$file = $suite;
if (str_contains($file, ':')) {
[$file] = explode(':', $suite, -1);
}
$realPath = $cwd . DIRECTORY_SEPARATOR . $file;
if (file_exists($realPath) && str_starts_with($realPath, $realTestDir)) {
//only match test if file is in tests directory
return $this->matchTestFromFilename(
$cwd . DIRECTORY_SEPARATOR . $suite,
$realTestDir
);
}
}
}
return null;
}
/**
* Runs included suites recursively
*
* @param string[] $suites
* @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)
* @throws ConfigurationException
*/
protected function runIncludedSuites(
array $suites,
string $parentDir,
array $filterAppSuites = [],
array $filterSuitesByWildcard = [],
): void {
$defaultConfig = Configuration::config();
$absolutePath = Configuration::projectDir();
foreach ($suites as $relativePath) {
$currentDir = rtrim($parentDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $relativePath;
$config = Configuration::config($currentDir);
if (!empty($defaultConfig['groups'])) {
$groups = array_map(fn ($g): string => $absolutePath . $g, $defaultConfig['groups']);
Configuration::append(['groups' => $groups]);
}
$suites = Configuration::suites();
if ($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 {$currentDir}\n</fg=white;bg=magenta>"
);
$this->executed += $this->runSuites($suites, $this->options['skip']);
if (!empty($config['include'])) {
$this->runIncludedSuites($config['include'], $currentDir);
}
}
}
protected function currentNamespace(): string
{
$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'];
}
/**
* @param string[] $suites
* @param string[] $skippedSuites
* @return int Number of executed test suites
*/
protected function runSuites(array $suites, array $skippedSuites = []): int
{
$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;
}
/**
* @return string[]
*/
protected function matchTestFromFilename(string $filename, string $testsPath): array
{
$filter = '';
if (str_contains($filename, ':')) {
if ((PHP_OS === 'Windows' || PHP_OS === 'WINNT') && $filename[1] === ':') {
// match C:\...
[$drive, $path, $filter] = explode(':', $filename, 3);
$filename = $drive . ':' . $path;
} else {
[$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(string &$path): ?string
{
$testParts = explode(':', $path, 2);
if (count($testParts) > 1) {
[$path, $filter] = $testParts;
// 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 :
$caratPos = strpos($filter, '^');
if ($caratPos !== false) {
return substr_replace($filter, ':', $caratPos, 1);
}
return $filter;
}
return null;
}
/**
* @return string[]
*/
protected function passedOptionKeys(ArgvInput $input): array
{
$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 (str_starts_with($token, '--')) {
$options[] = substr($token, 2);
} elseif ($token[0] === '-') {
$shortOption = substr($token, 1);
$options[] = $this->getDefinition()->getOptionForShortcut($shortOption)->getName();
}
}
return $options;
}
/**
* @return array<string, bool>
*/
protected function booleanOptions(ArgvInput $input, array $options = []): array
{
$values = [];
$request = (string)$input;
foreach ($options as $option => $defaultValue) {
if (strpos($request, sprintf('--%s', $option))) {
$values[$option] = $input->getOption($option) ?: $defaultValue;
} else {
$values[$option] = false;
}
}
return $values;
}
/**
* @throws Exception
*/
private function ensurePhpExtIsAvailable(string $ext): void
{
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."
);
}
}
private function isWildcardSuiteName(string $suiteName): bool
{
return str_starts_with($suiteName, '*::');
}
private function isSuiteInMultiApplication(string $suiteName): bool
{
return str_contains($suiteName, '::');
}
private function addRuntimeOptionsToCurrentConfig(array $config): array
{
// update config from options
if (count($this->options['override'])) {
$config = $this->overrideConfig($this->options['override']);
}
// enable extensions
if ($this->options['ext']) {
return $this->enableExtensions($this->options['ext']);
}
return $config;
}
}

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Codeception\Command;
use Codeception\Codecept;
use Exception;
use Humbug\SelfUpdate\Updater;
use Phar;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function sprintf;
/**
* 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
{
/**
* @var string
*/
public const NAME = 'Codeception';
/**
* @var string
*/
public const GITHUB_REPO = 'Codeception/Codeception';
/**
* @var string
*/
public const PHAR_URL = 'https://codeception.com/php80/';
/**
* Holds the current script filename.
*/
protected string $filename;
protected function configure(): void
{
$this->filename = $_SERVER['argv'][0] ?? Phar::running(false);
$this
->setAliases(['selfupdate'])
->setDescription(sprintf('Upgrade <comment>%s</comment> to the latest version', $this->filename));
}
protected function getCurrentVersion(): string
{
return Codecept::VERSION;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln(
sprintf('<info>%s</info> version <comment>%s</comment>', self::NAME, $this->getCurrentVersion())
);
$updater = new Updater(null, false);
$updater->getStrategy()->setPharUrl(self::PHAR_URL . 'codecept.phar');
$updater->getStrategy()->setVersionUrl(self::PHAR_URL . 'codecept.version');
try {
if ($updater->hasUpdate()) {
$output->writeln("\n<info>Updating...</info>");
$updater->update();
$output->writeln("\n<comment>{$this->filename}</comment> has been updated.\n");
} else {
$output->writeln('You are already using the latest version.');
}
} catch (Exception $exception) {
$output->writeln("<error>\n{$exception->getMessage()}\n</error>");
return Command::FAILURE;
}
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Codeception\Command\Shared;
use Codeception\Scenario;
trait ActorTrait
{
protected function getActorClassName(): ?string
{
if (empty($this->settings['actor'])) {
return null;
}
$namespace = "";
if ($this->settings['namespace']) {
$namespace .= '\\' . $this->settings['namespace'];
}
if (isset($this->settings['support_namespace'])) {
$namespace .= '\\' . $this->settings['support_namespace'];
}
$namespace = rtrim($namespace, '\\') . '\\';
return $namespace . $this->settings['actor'];
}
private function getActor($test): ?object
{
$actorClass = $this->getActorClassName();
return $actorClass ? new $actorClass(new Scenario($test)) : null;
}
}

View File

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Codeception\Command\Shared;
use Codeception\Configuration;
use InvalidArgumentException;
use Symfony\Component\Console\Exception\InvalidOptionException;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
use function array_merge_recursive;
use function array_pop;
use function array_shift;
use function class_exists;
use function count;
use function explode;
use function str_repeat;
use function ucfirst;
trait ConfigTrait
{
protected function getSuiteConfig(string $suite): array
{
return Configuration::suiteSettings($suite, $this->getGlobalConfig());
}
protected function getGlobalConfig(?string $conf = null): array
{
return Configuration::config($conf);
}
/**
* @return string[]
*/
protected function getSuites(): array
{
return Configuration::suites();
}
protected function overrideConfig($configOptions): array
{
$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): array
{
$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);
}
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Codeception\Command\Shared;
use Codeception\Util\Shared\Namespaces;
use function file_exists;
use function file_put_contents;
use function mkdir;
use function pathinfo;
use function preg_replace;
use function rtrim;
use function str_replace;
use function strrev;
trait FileSystemTrait
{
use Namespaces;
protected function createDirectoryFor(string $basePath, string $className = ''): string
{
$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(string $filename, string $suffix): string
{
if (str_starts_with(strrev($filename), strrev($suffix))) {
$filename .= '.php';
}
if (!str_starts_with(strrev($filename), strrev($suffix . '.php'))) {
$filename .= $suffix . '.php';
}
if (!str_starts_with(strrev($filename), strrev('.php'))) {
$filename .= '.php';
}
return $filename;
}
protected function removeSuffix(string $classname, string $suffix): string
{
$classname = preg_replace('#\.php$#', '', $classname);
return preg_replace("#{$suffix}$#", '', $classname);
}
protected function createFile(string $filename, string $contents, bool $force = false, int $flags = 0): bool
{
if (file_exists($filename) && !$force) {
return false;
}
file_put_contents($filename, $contents, $flags);
return true;
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Codeception\Command\Shared;
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
use Symfony\Component\Console\Output\OutputInterface;
trait StyleTrait
{
public function addStyles(OutputInterface $output): void
{
$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'));
}
}

View File

@ -0,0 +1,779 @@
<?php
declare(strict_types=1);
namespace Codeception;
use Codeception\Exception\ConfigurationException;
use Codeception\Lib\ParamsLoader;
use Codeception\Step\ConditionalAssertion;
use Codeception\Util\Autoload;
use Codeception\Util\PathResolver;
use Codeception\Util\Template;
use Exception;
use InvalidArgumentException;
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
{
/**
* @var string[]
*/
protected static array $suites = [];
/**
* @var array<string, mixed>|null Current configuration
*/
protected static ?array $config = null;
/**
* @var array<mixed> environmental files configuration cache
*/
protected static array $envConfig = [];
/**
* @var string|null Directory containing main configuration file.
* @see self::projectDir()
*/
protected static ?string $dir = null;
/**
* @var string|null Directory of a base configuration file for the project with includes.
* @see self::projectDir()
*/
protected static ?string $baseDir = null;
/**
* @var string Current project output directory.
*/
protected static ?string $outputDir = null;
/**
* @var string|null Current project data directory. This directory is used to hold
* sql dumps and other things needed for current project tests.
*/
protected static ?string $dataDir = null;
/**
* @var string|null Directory with test support files like Actors, Helpers, PageObjects, etc
*/
protected static ?string $supportDir = null;
/**
* @var string|null Directory containing environment configuration files.
*/
protected static ?string $envsDir = null;
/**
* @var string|null Directory containing tests and suites of the current project.
*/
protected static ?string $testsDir = null;
public static bool $lock = false;
/**
* @var array<string, mixed>
*/
public static array $defaultConfig = [
'actor_suffix' => 'Tester',
'support_namespace' => null,
'namespace' => '',
'include' => [],
'paths' => [],
'extends' => null,
'suites' => [],
'modules' => [],
'extensions' => [
'enabled' => [],
'config' => [],
'commands' => [],
],
'groups' => [],
'bootstrap' => false,
'settings' => [
'colors' => true,
'bootstrap' => false,
'strict_xml' => false,
'lint' => true,
'backup_globals' => true,
'report_useless_tests' => false,
'be_strict_about_changes_to_global_state' => false,
'shuffle' => false,
],
'coverage' => [],
'params' => [],
'gherkin' => []
];
/**
* @var array<string, mixed>
*/
public static array $defaultSuiteSettings = [
'actor' => null,
'modules' => [
'enabled' => [],
'config' => [],
'depends' => []
],
'step_decorators' => ConditionalAssertion::class,
'path' => null,
'extends' => null,
'namespace' => null,
'groups' => [],
'formats' => [],
'shuffle' => false,
'extensions' => [ // suite extensions
'enabled' => [],
'config' => [],
],
'error_level' => 'E_ALL & ~E_DEPRECATED',
'convert_deprecations_to_exceptions' => false,
];
/**
* @var array<string, mixed>|null
*/
protected static ?array $params = null;
/**
* Loads global config file which is `codeception.yml` by default.
* When config is already loaded - returns it.
*
* @return array<string, mixed>
* @throws ConfigurationException
*/
public static function config(?string $configFile = null): array
{
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));
if ($dir !== false) {
self::$dir = $dir;
$configDistFile = $dir . DIRECTORY_SEPARATOR . 'codeception.dist.yml';
// set the one default base directory for included setup
if (!self::$baseDir) {
self::$baseDir = $dir;
}
}
if (!file_exists($configFile) && (!isset($configDistFile) || !file_exists($configDistFile))) {
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 (isset($configDistFile) && file_exists($configDistFile)) {
$distConfigContents = file_get_contents($configDistFile);
if ($distConfigContents === false) {
throw new ConfigurationException("Failed to read {$configDistFile}");
}
$tempConfig = self::mergeConfigs($tempConfig, self::getConfFromContents($distConfigContents, $configDistFile));
}
$configContents = '';
if (file_exists($configFile)) {
$configContents = file_get_contents($configFile);
if ($configContents === false) {
throw new ConfigurationException("Failed to read {$configFile}");
}
$tempConfig = self::mergeConfigs($tempConfig, self::getConfFromContents($configContents, $configFile));
}
self::prepareParams($tempConfig);
// load config using params
$config = self::$defaultConfig;
if (isset($configDistFile) && $distConfigContents !== '') {
$config = self::mergeConfigs(self::$defaultConfig, self::getConfFromContents($distConfigContents, $configDistFile));
}
if ($configContents !== '') {
$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;
if (!isset($config['paths']['support']) && 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 (!empty($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::$config['support_namespace'], self::supportDir());
self::loadBootstrap($config['bootstrap'], self::testsDir());
self::loadSuites();
return $config;
}
/**
* @throws ConfigurationException
*/
public static function loadBootstrap(string|false $bootstrap, string $path): void
{
if (!$bootstrap) {
return;
}
$bootstrap = 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(): void
{
$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 array<mixed> $config
* @return array<string, string>
* @throws Exception
*/
public static function suiteSettings(string $suite, array $config): array
{
// cut namespace name from suite name
if ($suite != $config['namespace'] && str_starts_with($suite, $config['namespace'])) {
$suite = ltrim(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', 'support_namespace', '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['path']) {
// take a suite path from its name
$settings['path'] = $suite;
}
$config['paths']['tests'] = str_replace('/', DIRECTORY_SEPARATOR, (string) $config['paths']['tests']);
$settings['path'] = self::$dir . DIRECTORY_SEPARATOR . $config['paths']['tests']
. DIRECTORY_SEPARATOR . $settings['path'] . DIRECTORY_SEPARATOR;
$settings['suite'] = $suite;
$settings['suite_namespace'] = $settings['namespace'] . '\\' . $suite;
return $settings;
}
/**
* Loads environments configuration from set directory
*
* @param string $path Path to the directory
* @return array<string, mixed>
*/
protected static function loadEnvConfigs(string $path): array
{
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, []);
$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<string, mixed>
* @throws ConfigurationException
*/
protected static function getConfFromContents(string $contents, string $filename = '(.yml)'): array
{
if (self::$params) {
// replace '%var%' with encoded value
$singleQuoteTemplate = new Template($contents, "'%", "%'", 'json_encode');
$singleQuoteTemplate->setVars(self::$params);
$contents = $singleQuoteTemplate->produce();
// replace "%var%" with encoded value
$doubleQuoteTemplate = new Template($contents, '"%', '%"', 'json_encode');
$doubleQuoteTemplate->setVars(self::$params);
$contents = $doubleQuoteTemplate->produce();
// replace %var% with string value as is
$plainTemplate = new Template($contents, '%', '%');
$plainTemplate->setVars(self::$params);
$contents = $plainTemplate->produce();
}
try {
$conf = 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()
)
);
}
if ($conf === null) {
throw new ConfigurationException("Configuration file {$filename} is empty.");
}
if (!is_array($conf)) {
throw new ConfigurationException("Configuration file {$filename} is invalid.");
}
return $conf;
}
/**
* Loads configuration from Yaml file or returns given value if the file doesn't exist
*
* @param array<string, mixed> $nonExistentValue Value used if filename is not found
* @return array<string, mixed>
* @throws ConfigurationException
*/
protected static function getConfFromFile(string $filename, array $nonExistentValue = []): array
{
if (file_exists($filename)) {
$yaml = file_get_contents($filename);
if ($yaml === false) {
throw new ConfigurationException("Failed to read {$filename}");
}
return self::getConfFromContents($yaml, $filename);
}
return $nonExistentValue;
}
/**
* @return string[]
*/
public static function suites(): array
{
return self::$suites;
}
/**
* Return list of enabled modules according suite config.
*
* @param array<string, mixed> $settings Suite settings
* @return string[]
*/
public static function modules(array $settings): array
{
return array_filter(
array_map(
fn ($m): mixed => is_array($m) ? key($m) : $m,
$settings['modules']['enabled'],
array_keys($settings['modules']['enabled'])
),
function ($m) use ($settings): bool {
if (!isset($settings['modules']['disabled'])) {
return true;
}
return !in_array($m, $settings['modules']['disabled']);
}
);
}
public static function isExtensionEnabled(string $extensionName): bool
{
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.
*/
public static function dataDir(): string
{
return self::$dir . DIRECTORY_SEPARATOR . self::$dataDir . DIRECTORY_SEPARATOR;
}
/**
* Return current path to `_helpers` dir.
* Helpers are custom modules.
*/
public static function supportDir(): string
{
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.
*
* @throws ConfigurationException
*/
public static function outputDir(): string
{
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;
}
/**
* 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.
*/
public static function projectDir(): string
{
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()`
*/
public static function baseDir(): string
{
return self::$baseDir . DIRECTORY_SEPARATOR;
}
/**
* Returns path to tests directory
*/
public static function testsDir(): string
{
return self::$dir . DIRECTORY_SEPARATOR . self::$testsDir . DIRECTORY_SEPARATOR;
}
/**
* Return current path to `_envs` dir.
* Use it to store environment specific configuration.
*/
public static function envsDir(): string
{
if (self::$envsDir === '') {
return '';
}
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.
*/
public static function isEmpty(): bool
{
return !(bool)self::$testsDir;
}
/**
* Adds parameters to config
* @param array<string, mixed> $config
* @return array<string, mixed>
*/
public static function append(array $config = []): array
{
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;
}
/**
* @param array<mixed> $a1
* @param array<mixed> $a2
* @return array<mixed>
*/
public static function mergeConfigs(array $a1, array $a2): array
{
// for sequential arrays
if (isset($a1[0], $a2[0])) {
return array_values(array_unique(array_merge_recursive($a2, $a1), SORT_REGULAR));
}
// for associative arrays
$res = [];
foreach ($a2 as $k2 => $v2) {
if (!isset($a1[$k2]) || !is_array($a1[$k2])) { // if no such key
$res[$k2] = $v2;
unset($a1[$k2]);
continue;
}
if (is_array($v2)) {
$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 array<string ,mixed> $settings
* @return array<string ,mixed>
* @throws ConfigurationException
*/
protected static function loadSuiteConfig(string $suite, string $path, array $settings): array
{
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 ($presetFilePath === false) {
throw new ConfigurationException(
sprintf("Configuration file %s does not exist", $suiteConf['extends'])
);
}
if (file_exists($presetFilePath)) {
$settings = self::mergeConfigs(self::getConfFromFile($presetFilePath, []), $settings);
}
}
$settings = self::mergeConfigs($settings, $suiteDistConf);
return self::mergeConfigs($settings, $suiteConf);
}
/**
* Replaces wildcarded items in include array with real paths.
*
* @param string[] $includes
* @return string[]
* @throws ConfigurationException
*/
protected static function expandWildcardedIncludes(array $includes): array
{
if ($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.
*
* @return string[]
* @throws ConfigurationException
*/
protected static function expandWildcardsFor(string $include): array
{
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) {
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);
}
/**
* @param array<string, mixed> $settings
* @throws ConfigurationException
*/
private static function prepareParams(array $settings): void
{
self::$params = [];
foreach ($settings['params'] as $paramStorage) {
static::$params = array_merge(self::$params, ParamsLoader::load($paramStorage));
}
}
}

View File

@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace Codeception\Coverage;
use Codeception\Configuration;
use Codeception\Exception\ConfigurationException;
use Codeception\Exception\ModuleException;
use PHPUnit\Runner\Version as PHPUnitVersion;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Filter as PhpUnitFilter;
use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
use Symfony\Component\Finder\Finder;
use function array_pop;
use function explode;
use function implode;
use function is_array;
use function iterator_to_array;
use function str_replace;
class Filter
{
protected static ?self $codeceptionFilter = null;
protected ?PhpUnitFilter $phpUnitFilter = null;
public function __construct(protected ?CodeCoverage $phpCodeCoverage)
{
$this->phpUnitFilter = $this->phpCodeCoverage->filter();
}
public static function setup(CodeCoverage $phpCoverage): self
{
self::$codeceptionFilter = new self($phpCoverage);
return self::$codeceptionFilter;
}
/**
* @throws ConfigurationException
*/
public function whiteList(array $config): self
{
$filter = $this->phpUnitFilter;
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 (PHPUnitVersion::series() >= 11) {
return $this->newWhiteList($coverage['whitelist']);
}
foreach (['include', 'exclude'] as $type) {
if (!isset($coverage['whitelist'][$type])) {
continue;
}
if (!is_array($coverage['whitelist'][$type])) {
throw new ConfigurationException("Error parsing yaml. Config `whitelist: {$type}:` should be an array");
}
foreach ($coverage['whitelist'][$type] as $fileOrDir) {
try {
$finder = str_contains($fileOrDir, '*')
? $this->matchWildcardPattern($fileOrDir)
: [Configuration::projectDir() . DIRECTORY_SEPARATOR . $fileOrDir];
foreach ($finder as $file) {
$file = (string) $file;
$type === 'include' ? $filter->includeFile($file) : $filter->excludeFile($file);
}
} catch (DirectoryNotFoundException) {
continue;
}
}
}
return $this;
}
private function newWhiteList(array $whitelist): self
{
$include = $whitelist['include'] ?? [];
$exclude = $whitelist['exclude'] ?? [];
if (!is_array($include)) {
throw new ConfigurationException('Error parsing yaml. Config `whitelist: include:` should be an array');
}
if (!is_array($exclude)) {
throw new ConfigurationException('Error parsing yaml. Config `whitelist: exclude:` should be an array');
}
if ($exclude === [] && $include === []) {
return $this;
}
if ($include === []) {
$include = [
Configuration::projectDir() . DIRECTORY_SEPARATOR . '*'
];
}
$allIncludedFiles = $this->matchFiles($include);
$allExcludedFiles = $this->matchFiles($exclude);
$coveredFiles = array_diff($allIncludedFiles, $allExcludedFiles);
foreach ($coveredFiles as $coveredFile) {
$this->phpUnitFilter->includeFile((string) $coveredFile);
}
return $this;
}
private function matchFiles(array $files): array
{
$matchedFiles = [];
foreach ($files as $fileOrDir) {
try {
$finder = str_contains($fileOrDir, '*')
? $this->matchWildcardPattern($fileOrDir)
: $this->matchFileOrDirectory($fileOrDir);
$matchedFiles += iterator_to_array($finder->getIterator());
} catch (DirectoryNotFoundException) {
continue;
}
}
return $matchedFiles;
}
/**
* @throws ModuleException
*/
public function blackList(array $config): self
{
if (isset($config['coverage']['blacklist'])) {
throw new ModuleException($this, 'The blacklist functionality has been removed from PHPUnit 5,'
. ' please remove blacklist section from configuration.');
}
return $this;
}
private function matchFileOrDirectory(string $fileOrDir): Finder
{
$fullPath = Configuration::projectDir() . $fileOrDir;
$finder = Finder::create();
if (is_dir($fullPath)) {
$finder->in($fullPath);
$finder->name('*.php');
} else {
$finder->in(dirname($fullPath));
$finder->name(basename($fullPath));
}
$finder->ignoreVCS(true)->files();
return $finder;
}
protected function matchWildcardPattern(string $pattern): Finder
{
$finder = Finder::create();
$fileOrDir = str_replace('\\', '/', $pattern);
$parts = explode('/', $fileOrDir);
$file = array_pop($parts);
if ($file === '*') {
$file = '*.php';
}
$finder->name($file);
if ($parts !== []) {
$lastPath = array_pop($parts);
$path = implode('/', ($lastPath === '*' ? $parts : [...$parts, $lastPath]));
$finder->in(Configuration::projectDir() . $path);
}
$finder->ignoreVCS(true)->files();
return $finder;
}
public function getFilter(): PhpUnitFilter
{
return $this->phpUnitFilter;
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Codeception\Coverage;
use Codeception\Configuration;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\Selector;
use SebastianBergmann\CodeCoverage\Filter as CodeCoverageFilter;
class PhpCodeCoverageFactory
{
private static ?CodeCoverage $instance = null;
public static function build(): CodeCoverage
{
if (self::$instance instanceof CodeCoverage) {
return self::$instance;
}
$coverageConfig = Configuration::config()['coverage'];
$pathCoverage = $coverageConfig['path_coverage'] ?? false;
$filter = new CodeCoverageFilter();
$selector = new Selector();
$driver = $pathCoverage ? $selector->forLineAndPathCoverage($filter) : $selector->forLineCoverage($filter);
return self::$instance = new CodeCoverage($driver, $filter);
}
public static function clear(): void
{
self::$instance = null;
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Codeception\Coverage\Subscriber;
use Codeception\Coverage\Filter;
use Codeception\Coverage\PhpCodeCoverageFactory;
use Codeception\Coverage\SuiteSubscriber;
use Codeception\Event\SuiteEvent;
use Codeception\Events;
use Codeception\Exception\ConfigurationException;
use Codeception\Exception\ModuleException;
use Codeception\Lib\Interfaces\Remote;
use Exception;
/**
* Collects code coverage from unit and functional tests.
* Results from all suites are merged.
*/
class Local extends SuiteSubscriber
{
/**
* @var array<string, string>
*/
public static array $events = [
Events::SUITE_BEFORE => 'beforeSuite',
Events::SUITE_AFTER => 'afterSuite',
];
protected ?Remote $module = null;
protected function isEnabled(): bool
{
return !$this->module instanceof Remote && $this->settings['enabled'];
}
/**
* @throws ConfigurationException|ModuleException|Exception
*/
public function beforeSuite(SuiteEvent $event): void
{
$this->applySettings($event->getSettings());
$this->module = $this->getServerConnectionModule($event->getSuite()->getModules());
if (!$this->isEnabled()) {
return;
}
$event->getSuite()->collectCodeCoverage(true);
Filter::setup($this->coverage)
->whiteList($this->filters)
->blackList($this->filters);
}
public function afterSuite(SuiteEvent $event): void
{
if (!$this->isEnabled()) {
return;
}
$codeCoverage = PhpCodeCoverageFactory::build();
PhpCodeCoverageFactory::clear();
$this->mergeToPrint($codeCoverage);
}
}

View File

@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
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 Codeception\Lib\Interfaces\Web as WebInterface;
use Codeception\Lib\Notification;
use Codeception\Module\WebDriver as WebDriverModule;
use Facebook\WebDriver\Exception\NoSuchAlertException;
use RuntimeException;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use function array_filter;
use function array_replace_recursive;
use function file_exists;
use function file_get_contents;
use function json_encode;
use function parse_url;
use function preg_match;
use function str_replace;
use function stream_context_create;
use function unserialize;
use function usleep;
/**
* 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
/**
* @var string
*/
public const COVERAGE_HEADER = 'X-Codeception-CodeCoverage';
/**
* @var string
*/
public const COVERAGE_HEADER_ERROR = 'X-Codeception-CodeCoverage-Error';
/**
* @var string
*/
public const COVERAGE_HEADER_CONFIG = 'X-Codeception-CodeCoverage-Config';
/**
* @var string
*/
public const COVERAGE_HEADER_SUITE = 'X-Codeception-CodeCoverage-Suite';
// cookie names
/**
* @var string
*/
public const COVERAGE_COOKIE = 'CODECEPTION_CODECOVERAGE';
/**
* @var string
*/
public const COVERAGE_COOKIE_ERROR = 'CODECEPTION_CODECOVERAGE_ERROR';
protected string $suiteName = '';
protected array $c3Access = [
'http' => [
'method' => 'GET',
'header' => ''
]
];
protected ?WebInterface $module = null;
/**
* @var array<string, string>
*/
public static array $events = [
Events::SUITE_BEFORE => 'beforeSuite',
Events::TEST_BEFORE => 'beforeTest',
Events::STEP_AFTER => 'afterStep',
Events::SUITE_AFTER => 'afterSuite',
];
protected function isEnabled(): bool
{
return $this->module && !$this->settings['remote'] && $this->settings['enabled'];
}
public function beforeSuite(SuiteEvent $event): void
{
$this->module = $this->getServerConnectionModule($event->getSuite()->getModules());
$this->applySettings($event->getSettings());
if (!$this->isEnabled()) {
return;
}
$this->suiteName = $event->getSuite()->getBaseName();
if ($this->settings['remote_config']) {
$this->addC3AccessHeader(self::COVERAGE_HEADER_CONFIG, $this->settings['remote_config']);
if ($this->c3Request('clear') === 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 $event): void
{
if (!$this->isEnabled()) {
return;
}
$this->startCoverageCollection($event->getTest()->getName());
}
public function afterStep(StepEvent $event): void
{
if (!$this->isEnabled()) {
return;
}
$this->fetchErrors();
}
public function afterSuite(SuiteEvent $event): void
{
if (!$this->isEnabled()) {
return;
}
$outputDir = Configuration::outputDir() . 'c3tmp/';
$blockFile = $outputDir . 'block_report';
$coverageFile = $outputDir . 'codecoverage.serialized';
$errorFile = $outputDir . 'error.txt';
$this->waitForFile($blockFile, 120, 250_000);
$this->waitForFile($coverageFile, 5, 500_000);
if (!file_exists($coverageFile)) {
throw new RuntimeException(
file_exists($errorFile) ? file_get_contents($errorFile) : "Code coverage file {$coverageFile} does not exist"
);
}
if ($coverage = @unserialize(file_get_contents($coverageFile))) {
$this->preProcessCoverage($coverage)->mergeToPrint($coverage);
}
}
/**
* Allows Translating Remote Paths To Local (IE: When Using Docker)
*/
protected function preProcessCoverage(CodeCoverage $coverage): self
{
if (!$this->settings['work_dir']) {
return $this;
}
$workDir = rtrim((string) $this->settings['work_dir'], '/\\') . DIRECTORY_SEPARATOR;
$projectDir = Configuration::projectDir();
$coverageData = $coverage->getData(true); // We only want covered files, not all whitelisted ones.
codecept_debug("Replacing all instances of {$workDir} with {$projectDir}");
foreach ($coverageData as $path => $datum) {
unset($coverageData[$path]);
$path = str_replace($workDir, $projectDir, (string) $path);
$coverageData[$path] = $datum;
}
$coverage->setData($coverageData);
return $this;
}
protected function c3Request(string $action): string|false
{
$this->addC3AccessHeader(self::COVERAGE_HEADER, 'remote-access');
$context = stream_context_create($this->c3Access);
$c3Url = $this->settings['c3_url'] ?? $this->module->_getUrl();
$contents = file_get_contents("{$c3Url}/c3/report/{$action}", false, $context);
$okHeaders = array_filter(
$http_response_header,
fn ($h) => preg_match('#^HTTP(.*?)\s200#', $h)
);
if ($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(string $testName): void
{
$coverageDataJson = json_encode([
'CodeCoverage' => $testName,
'CodeCoverage_Suite' => $this->suiteName,
'CodeCoverage_Config' => $this->settings['remote_config']
], JSON_THROW_ON_ERROR);
if ($this->module instanceof WebDriverModule) {
$this->module->amOnPage('/');
}
$cookieDomain = $this->settings['cookie_domain'] ??
parse_url($this->settings['c3_url'] ?? $this->module->_getUrl(), PHP_URL_HOST) ??
'localhost';
if (!$cookieDomain) {
// we need to separate coverage cookies by host; we can't separate cookies by port.
$cookieDomain = 'localhost';
}
$cookieParams = $cookieDomain !== 'localhost' ? ['domain' => $cookieDomain] : [];
$this->module->setCookie(self::COVERAGE_COOKIE, $coverageDataJson, $cookieParams);
// putting in configuration ensures the cookie is used for all sessions of a MultiSession test
$cookies = $this->module->_getConfig('cookies');
if (!is_array($cookies)) {
$cookies = [];
}
$cookieUpdated = false;
foreach ($cookies as &$cookie) {
if (isset($cookie['Name'], $cookie['Value']) && $cookie['Name'] === self::COVERAGE_COOKIE) {
$cookie['Value'] = $coverageDataJson;
$cookieUpdated = true;
break;
}
// \Codeception\Lib\InnerBrowser will complain about this
}
unset($cookie);
if (!$cookieUpdated) {
$cookies[] = [
'Name' => self::COVERAGE_COOKIE,
'Value' => $coverageDataJson
];
}
$this->module->_setConfig(['cookies' => $cookies]);
}
protected function fetchErrors(): void
{
// Calling grabCookie() while an alert is present dismisses the alert
// @see https://github.com/Codeception/Codeception/issues/1485
if ($this->module instanceof WebDriverModule) {
try {
$this->module->webDriver->switchTo()->alert()->getText();
// If this succeeds an alert is present, abort
return;
} catch (NoSuchAlertException) {
// No alert present, continue
}
}
try {
$error = $this->module->grabCookie(self::COVERAGE_COOKIE_ERROR);
} catch (ModuleException) {
// 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
return;
}
if (!empty($error)) {
$this->module->resetCookie(self::COVERAGE_COOKIE_ERROR);
throw new RemoteException($error);
}
}
/** @param string[] $headers */
protected function getRemoteError(array $headers): void
{
foreach ($headers as $header) {
if (str_starts_with($header, self::COVERAGE_HEADER_ERROR)) {
throw new RemoteException($header);
}
}
}
protected function addC3AccessHeader(string $header, string $value): void
{
$headerString = "{$header}: {$value}\r\n";
if (!str_contains((string) $this->c3Access['http']['header'], $headerString)) {
$this->c3Access['http']['header'] .= $headerString;
}
}
protected function applySettings(array $settings): void
{
parent::applySettings($settings);
if (isset($settings['coverage']['remote_context_options'])) {
$this->c3Access = array_replace_recursive($this->c3Access, $settings['coverage']['remote_context_options']);
}
}
private function waitForFile(string $file, int $maxRetries, int $sleepTime): void
{
$retries = $maxRetries;
while ($retries > 0 && (!file_exists($file) || file_get_contents($file) !== '0')) {
usleep($sleepTime);
$retries--;
}
if (!file_exists($file) || file_get_contents($file) !== '0') {
Notification::warning('Timeout: Some coverage data is not included in the coverage report.', '');
}
}
}

View File

@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace Codeception\Coverage\Subscriber;
use Codeception\Configuration;
use Codeception\Coverage\Filter;
use Codeception\Coverage\PhpCodeCoverageFactory;
use Codeception\Event\PrintResultEvent;
use Codeception\Events;
use Codeception\Lib\Console\Output;
use Codeception\Subscriber\Shared\StaticEventsTrait;
use PHPUnit\Runner\Version as PHPUnitVersion;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Report\Clover as CloverReport;
use SebastianBergmann\CodeCoverage\Report\Cobertura as CoberturaReport;
use SebastianBergmann\CodeCoverage\Report\Crap4j as Crap4jReport;
use SebastianBergmann\CodeCoverage\Report\Html\Facade as HtmlFacadeReport;
use SebastianBergmann\CodeCoverage\Report\PHP as PhpReport;
use SebastianBergmann\CodeCoverage\Report\Text as TextReport;
use SebastianBergmann\CodeCoverage\Report\Thresholds;
use SebastianBergmann\CodeCoverage\Report\Xml\Facade as XmlFacadeReport;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use function array_merge;
use function file_put_contents;
use function str_starts_with;
use function strpos;
class Printer implements EventSubscriberInterface
{
use StaticEventsTrait;
/**
* @var array<string, string>
*/
public static array $events = [
Events::RESULT_PRINT_AFTER => 'printResult'
];
protected array $settings = [
'enabled' => true,
'low_limit' => 35,
'high_limit' => 70,
'show_uncovered' => false,
'show_only_summary' => false
];
public static CodeCoverage $coverage;
protected string $logDir;
public function __construct(protected array $options, private readonly Output $output)
{
$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());
$filter->blackList(Configuration::config());
}
protected function absolutePath(string $path): string
{
if (str_starts_with($path, '/') || strpos($path, ':') === 1) { // absolute path
return $path;
}
return $this->logDir . $path;
}
public function printResult(PrintResultEvent $event): void
{
if (!$this->settings['enabled']) {
$this->output->write("\nCodeCoverage is disabled in `codeception.yml` config\n");
return;
}
if (!$this->options['quiet']) {
$this->printConsole();
}
$this->output->write("Remote CodeCoverage reports are not printed to console\n");
if ($this->options['disable-coverage-php'] === true) {
$this->output->write("PHP serialized report was skipped\n");
} else {
$this->printPHP();
}
$this->output->write("\n");
$reports = [
'HTML' => ['name' => 'coverage-html', 'method' => 'printHTML'],
'XML' => ['name' => 'coverage-xml', 'method' => 'printXML'],
'Text' => ['name' => 'coverage-text', 'method' => 'printText'],
'Crap4j' => ['name' => 'coverage-crap4j', 'method' => 'printCrap4j'],
'Cobertura' => ['name' => 'coverage-cobertura', 'method' => 'printCobertura'],
'PHPUnit' => ['name' => 'coverage-phpunit', 'method' => 'printPHPUnit'],
];
foreach ($reports as $reportType => $reportData) {
if ($option = $this->options[$reportData['name']]) {
$this->{$reportData['method']}();
$this->output->write("{$reportType} report generated in {$option}\n");
}
}
}
protected function printConsole(): void
{
$writer = $this->createTextWriter();
$this->output->write($writer->process(self::$coverage, $this->options['colors']));
}
protected function printHtml(): void
{
$writer = $this->createHtmlFacadeWriter();
$writer->process(self::$coverage, $this->absolutePath($this->options['coverage-html']));
}
protected function printXml(): void
{
$writer = new CloverReport();
$writer->process(self::$coverage, $this->absolutePath($this->options['coverage-xml']));
}
protected function printPHP(): void
{
$writer = new PhpReport();
$writer->process(self::$coverage, $this->absolutePath($this->options['coverage']));
}
protected function printText(): void
{
$writer = $this->createTextWriter();
file_put_contents(
$this->absolutePath($this->options['coverage-text']),
$writer->process(self::$coverage)
);
}
protected function printCrap4j(): void
{
$writer = new Crap4jReport();
$writer->process(self::$coverage, $this->absolutePath($this->options['coverage-crap4j']));
}
protected function printCobertura(): void
{
$writer = new CoberturaReport();
$writer->process(self::$coverage, $this->absolutePath($this->options['coverage-cobertura']));
}
protected function printPHPUnit(): void
{
$writer = new XmlFacadeReport(PHPUnitVersion::id());
$writer->process(self::$coverage, $this->absolutePath($this->options['coverage-phpunit']));
}
private function createHtmlFacadeWriter(): HtmlFacadeReport
{
$generator = ', <a href="https://codeception.com">Codeception</a> and <a href="https://phpunit.de/">PHPUnit {PHPUnitVersion::id()}</a>';
return PHPUnitVersion::series() < 10 ?
new HtmlFacadeReport($this->settings['low_limit'], $this->settings['high_limit'], $generator) :
new HtmlFacadeReport($generator, null, Thresholds::from($this->settings['low_limit'], $this->settings['high_limit']));
}
private function createTextWriter(): TextReport
{
return PHPUnitVersion::series() < 10 ?
new TextReport($this->settings['low_limit'], $this->settings['high_limit'], $this->settings['show_uncovered'], $this->settings['show_only_summary']) :
new TextReport(Thresholds::from($this->settings['low_limit'], $this->settings['high_limit']), $this->settings['show_uncovered'], $this->settings['show_only_summary']);
}
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Codeception\Coverage\Subscriber;
use Codeception\Configuration;
use Codeception\Event\SuiteEvent;
use Codeception\Util\FileSystem;
use PharData;
use function file_put_contents;
use function is_dir;
use function mkdir;
use function strtr;
use function sys_get_temp_dir;
use function tempnam;
use function unlink;
/**
* 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(): bool
{
return $this->module && $this->settings['remote'] && $this->settings['enabled'];
}
public function afterSuite(SuiteEvent $event): void
{
if (!$this->isEnabled()) {
return;
}
$suite = strtr($event->getSuite()->getName(), ['\\' => '.']);
if ($this->options['coverage-xml']) {
$this->retrieveAndPrint('clover', $suite, '.remote.coverage.xml');
}
if ($this->options['coverage-html']) {
$this->retrieveToTempFileAndPrint('html', $suite, '.remote.coverage');
}
if ($this->options['coverage-crap4j']) {
$this->retrieveAndPrint('crap4j', $suite, '.remote.crap4j.xml');
}
if ($this->options['coverage-cobertura']) {
$this->retrieveAndPrint('cobertura', $suite, '.remote.cobertura.xml');
}
if ($this->options['coverage-phpunit']) {
$this->retrieveToTempFileAndPrint('phpunit', $suite, '.remote.coverage-phpunit');
}
}
protected function retrieveAndPrint(string $type, string $suite, string $extension): void
{
$destFile = Configuration::outputDir() . $suite . $extension;
file_put_contents($destFile, $this->c3Request($type));
}
protected function retrieveToTempFileAndPrint(string $type, string $suite, string $extension): void
{
$tempFile = tempnam(sys_get_temp_dir(), 'C3') . '.tar';
file_put_contents($tempFile, $this->c3Request($type));
$destDir = Configuration::outputDir() . $suite . $extension;
if (is_dir($destDir)) {
FileSystem::doEmptyDir($destDir);
} else {
mkdir($destDir, 0777, true);
}
$pharData = new PharData($tempFile);
$pharData->extractTo($destDir);
unlink($tempFile);
}
}

View File

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace Codeception\Coverage;
use Codeception\Configuration;
use Codeception\Coverage\Subscriber\Printer;
use Codeception\Exception\ConfigurationException;
use Codeception\Lib\Interfaces\Remote as RemoteInterface;
use Codeception\Subscriber\Shared\StaticEventsTrait;
use Exception;
use PHPUnit\Framework\CodeCoverageException;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use function array_keys;
abstract class SuiteSubscriber implements EventSubscriberInterface
{
use StaticEventsTrait;
protected array $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,
'path_coverage' => false,
'strict_covers_annotation' => false,
'ignore_deprecated_code' => false,
'disable_code_coverage_ignore' => false,
];
protected array $settings = [];
protected array $filters = [];
protected array $modules = [];
protected ?CodeCoverage $coverage = null;
protected string $logDir;
public static array $events = [];
abstract protected function isEnabled();
/**
* SuiteSubscriber constructor.
*
* @throws ConfigurationException
*/
public function __construct(protected array $options = [])
{
$this->logDir = Configuration::outputDir();
}
/**
* @throws Exception
*/
protected function applySettings(array $settings): void
{
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',
$e->getCode(),
$e
);
}
$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];
}
}
$this->configureCoverage();
}
protected function configureCoverage(): void
{
if ($this->settings['strict_covers_annotation']) {
$this->coverage->enableCheckForUnintentionallyCoveredCode();
}
if ($this->settings['ignore_deprecated_code']) {
$this->coverage->ignoreDeprecatedCode();
} else {
$this->coverage->doNotIgnoreDeprecatedCode();
}
if ($this->settings['disable_code_coverage_ignore']) {
$this->coverage->disableAnnotationsForIgnoringCode();
} else {
$this->coverage->enableAnnotationsForIgnoringCode();
}
if ($this->settings['show_uncovered']) {
$this->coverage->includeUncoveredFiles();
} else {
$this->coverage->excludeUncoveredFiles();
}
}
protected function getServerConnectionModule(array $modules): ?RemoteInterface
{
foreach ($modules as $module) {
if ($module instanceof RemoteInterface) {
return $module;
}
}
return null;
}
protected function mergeToPrint(CodeCoverage $coverage): void
{
Printer::$coverage->merge($coverage);
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Codeception;
interface CustomCommandInterface
{
/**
* returns the name of the command
*/
public static function getCommandName(): string;
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Codeception\Event;
use Codeception\Test\Test;
use Throwable;
class FailEvent extends TestEvent
{
public function __construct(Test $test, private Throwable $fail, ?float $time)
{
parent::__construct($test, $time);
}
public function getFail(): Throwable
{
return $this->fail;
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Codeception\Event;
use Codeception\ResultAggregator;
use Symfony\Contracts\EventDispatcher\Event;
class PrintResultEvent extends Event
{
public function __construct(protected ResultAggregator $result)
{
}
public function getResult(): ResultAggregator
{
return $this->result;
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Codeception\Event;
use Codeception\Step;
use Codeception\TestInterface;
use Symfony\Contracts\EventDispatcher\Event;
class StepEvent extends Event
{
public function __construct(protected TestInterface $test, protected Step $step)
{
}
public function getStep(): Step
{
return $this->step;
}
public function getTest(): TestInterface
{
return $this->test;
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Codeception\Event;
use Codeception\ResultAggregator;
use Codeception\Suite;
use Symfony\Contracts\EventDispatcher\Event;
class SuiteEvent extends Event
{
public function __construct(protected ?Suite $suite = null, protected array $settings = [])
{
}
public function getSuite(): ?Suite
{
return $this->suite;
}
public function getSettings(): array
{
return $this->settings;
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Codeception\Event;
use Codeception\Test\Test;
use Symfony\Contracts\EventDispatcher\Event;
class TestEvent extends Event
{
/**
* @param float|null $time Time taken
*/
public function __construct(protected Test $test, protected ?float $time = 0)
{
}
public function getTime(): float
{
return $this->time;
}
public function getTest(): Test
{
return $this->test;
}
}

View File

@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
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.
*
* @var string
*/
public 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.
*
* @var string
*/
public 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.
*
* @var string
*/
public 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.
*
* @var string
*/
public const SUITE_AFTER = 'suite.after';
/**
* The event listener method receives a {@link \Codeception\Event\TestEvent} instance.
*
* @var string
*/
public const TEST_START = 'test.start';
/**
* The event listener method receives a {@link \Codeception\Event\TestEvent} instance.
*
* @var string
*/
public const TEST_BEFORE = 'test.before';
/**
* The event listener method receives a {@link \Codeception\Event\StepEvent} instance.
*
* @var string
*/
public const STEP_BEFORE = 'step.before';
/**
* The event listener method receives a {@link \Codeception\Event\StepEvent} instance.
*
* @var string
*/
public 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.
*
* @var string
*/
public 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.
*
* @var string
*/
public const TEST_ERROR = 'test.error';
/**
* The event listener method receives a {@link \Codeception\Event\TestEvent} instance.
*
* @var string
*/
public const TEST_PARSED = 'test.parsed';
/**
* The event listener method receives a {@link \Codeception\Event\FailEvent} instance.
*
* @var string
*/
public const TEST_INCOMPLETE = 'test.incomplete';
/**
* The event listener method receives a {@link \Codeception\Event\FailEvent} instance.
*
* @var string
*/
public const TEST_SKIPPED = 'test.skipped';
/**
* The event listener method receives a {@link \Codeception\Event\FailEvent} instance.
*
* @var string
*/
public 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.
*
* @var string
*/
public const TEST_USELESS = 'test.useless';
/**
* The event listener method receives a {@link \Codeception\Event\TestEvent} instance.
*
* @var string
*/
public const TEST_SUCCESS = 'test.success';
/**
* The event listener method receives a {@link \Codeception\Event\TestEvent} instance.
*
* @var string
*/
public const TEST_AFTER = 'test.after';
/**
* The event listener method receives a {@link \Codeception\Event\TestEvent} instance.
*
* @var string
*/
public const TEST_END = 'test.end';
/**
* The event listener method receives a {@link \Codeception\Event\FailEvent} instance.
*
* @var string
*/
public const TEST_FAIL_PRINT = 'test.fail.print';
/**
* The event listener method receives a {@link \Codeception\Event\PrintResultEvent} instance.
*
* @var string
*/
public const RESULT_PRINT_AFTER = 'result.print.after';
}

View File

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Codeception;
use ArrayAccess;
use ArrayIterator;
use Countable;
use IteratorAggregate;
use PHPUnit\Framework\AssertionFailedError;
use Traversable;
class Example implements ArrayAccess, Countable, IteratorAggregate
{
public function __construct(protected $data)
{
}
/**
* Whether an offset exists
*
* @link https://php.net/manual/en/arrayaccess.offsetexists.php
* @param mixed $offset <p>An offset to check for.</p>
* @return bool true on success or false on failure.
* The return value will be casted to boolean if non-boolean was returned.
*/
public function offsetExists(mixed $offset): bool
{
return array_key_exists($offset, $this->data);
}
/**
* 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.
*/
public function offsetGet(mixed $offset): mixed
{
if (!$this->offsetExists($offset)) {
throw new AssertionFailedError(sprintf("Example %s doesn't exist", $offset));
}
return $this->data[$offset];
}
/**
* 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>
*/
public function offsetSet(mixed $offset, mixed $value): void
{
$this->data[$offset] = $value;
}
/**
* Offset to unset
*
* @link https://php.net/manual/en/arrayaccess.offsetunset.php
* @param mixed $offset <p>The offset to unset.</p>
*/
public function offsetUnset(mixed $offset): void
{
unset($this->data[$offset]);
}
/**
* Count elements of an object
*
* @link https://php.net/manual/en/countable.count.php
* @return int The custom count as an integer.
* The return value is cast to an integer.
*/
public function count(): int
{
return count($this->data);
}
/**
* 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>
*/
public function getIterator(): Traversable
{
return new ArrayIterator($this->data);
}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use PHPUnit\Framework\AssertionFailedError;
class ConditionalAssertionFailed extends AssertionFailedError
{
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use Exception;
class ConfigurationException extends Exception
{
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use PHPUnit\Framework\AssertionFailedError;
class ContentNotFound extends AssertionFailedError
{
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
class Deprecation extends Error
{
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use Exception;
class Error extends Exception
{
public function __construct(string $message, int $code, string $file, int $line, ?\Exception $previous = null)
{
parent::__construct($message, $code, $previous);
$this->file = $file;
$this->line = $line;
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use Exception;
use function is_object;
class ExtensionException extends Exception
{
/**
* ExtensionException constructor.
*
* @param object|string $extension
*/
public function __construct($extension, string $message, ?Exception $previous = null)
{
parent::__construct($message, 0, $previous);
if (is_object($extension)) {
$extension = $extension::class;
}
$this->message = $extension . "\n\n" . $this->message;
}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use Exception;
class InjectionException extends Exception
{
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use Exception;
class InvalidTestException extends Exception
{
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use Exception;
use function is_object;
use function ltrim;
use function str_replace;
class ModuleConfigException extends Exception
{
/**
* ModuleConfigException constructor.
*
* @param object|string $module
*/
public function __construct($module, string $message, ?Exception $previous = null)
{
if (is_object($module)) {
$module = $module::class;
}
$module = str_replace('Codeception\Module\\', '', ltrim($module, '\\'));
parent::__construct($message, 0, $previous);
$this->message = $module . " module is not configured!\n \n" . $this->message;
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use Exception;
use function is_object;
use function ltrim;
use function str_replace;
class ModuleConflictException extends Exception
{
/**
* ModuleConflictException constructor.
*
* @param object|string $module
* @param object|string $conflicted
*/
public function __construct($module, $conflicted, string $additional = '')
{
if (is_object($module)) {
$module = $module::class;
}
if (is_object($conflicted)) {
$conflicted = $conflicted::class;
}
$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;
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use Exception;
use function is_object;
use function ltrim;
use function str_replace;
class ModuleException extends Exception
{
protected string $module;
/**
* ModuleException constructor.
*
* @param object|string $module
*/
public function __construct($module, string $message)
{
if (is_object($module)) {
$module = $module::class;
}
$module = ltrim(str_replace('Codeception\Module\\', '', $module), '\\');
$this->module = $module;
parent::__construct($message);
$this->message = "{$module}: {$this->message}";
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use Exception;
use function is_object;
use function ltrim;
use function str_replace;
class ModuleRequireException extends Exception
{
/**
* ModuleRequireException constructor.
*
* @param object|string $module
*/
public function __construct($module, string $message)
{
if (is_object($module)) {
$module = $module::class;
}
$module = str_replace('Codeception\\Module\\', '', ltrim($module, '\\'));
parent::__construct($message);
$this->message = "[{$module}] module requirements not met --\n \n" . $this->message;
}
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
class Notice extends Error
{
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use Exception;
class ParseException extends Exception
{
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use Exception;
class RemoteException extends Exception
{
public function __construct(string $message)
{
parent::__construct($message);
$this->message = "Remote Application Error:\n" . $this->message;
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use Exception;
class TestParseException extends Exception
{
public function __construct(string $fileName, ?string $errors = null, ?int $line = null)
{
$this->message = "Couldn't parse test '{$fileName}'";
if ($line !== null) {
$this->message .= " on line {$line}";
}
if ($errors) {
$this->message .= PHP_EOL . $errors;
}
}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use RuntimeException;
class TestRuntimeException extends RuntimeException
{
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use Throwable;
class ThrowableWrapper extends Error
{
public function __construct(Throwable $throwable)
{
parent::__construct(
$throwable::class . ': ' . $throwable->getMessage(),
$throwable->getCode(),
$throwable->getFile(),
$throwable->getLine()
);
}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Codeception\Exception;
use PHPUnit\Framework\AssertionFailedError;
class UselessTestException extends AssertionFailedError
{
}

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