diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..b2ea9a7
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,25 @@
+# Define the line ending behavior of the different file extensions
+# Set default behavior, in case users don't have core.autocrlf set.
+* text text=auto eol=lf
+
+.php diff=php
+
+# Declare files that will always have CRLF line endings on checkout.
+*.bat eol=crlf
+
+# Declare files that will always have LF line endings on checkout.
+*.pem eol=lf
+
+# Denote all files that are truly binary and should not be modified.
+*.png binary
+*.jpg binary
+*.gif binary
+*.ico binary
+*.mo binary
+*.pdf binary
+*.phar binary
+*.woff binary
+*.woff2 binary
+*.ttf binary
+*.otf binary
+*.eot binary
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 40b2fe9..2a9d69c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -10,68 +10,48 @@ on:
- 'hotfix-*'
jobs:
- build71:
- runs-on: ubuntu-latest
+ tests:
+ name: PHP ${{ matrix.php-version }} on ${{ matrix.os }} (${{ matrix.composer-options }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version:
+ - "8.0"
+ - "8.1"
+ os:
+ - ubuntu-latest
+ - windows-latest
+ - macOS-latest
+ composer-options:
+ - ""
+ - "--prefer-lowest"
steps:
- - uses: actions/checkout@v2.3.4
- - uses: shivammathur/setup-php@2.14.0
- with:
- php-version: '7.1.3'
- - name: Install
- run: composer install
- - name: Install
- run: composer validate
- - name: Test
- run: composer test
- build72:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2.3.4
- - uses: shivammathur/setup-php@2.14.0
- with:
- php-version: '7.2'
- - name: Install
- run: composer install
- - name: Install
- run: composer validate
- - name: Test
- run: composer test
- build73:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2.3.4
- - uses: shivammathur/setup-php@2.14.0
- with:
- php-version: '7.3'
- - name: Install
- run: composer install
- - name: Install
+ - name: Checkout
+ uses: actions/checkout@v2.3.4
+
+ - name: Validate composer.json and composer.lock
run: composer validate
- - name: Test
- run: composer test
- build74:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2.3.4
- - uses: shivammathur/setup-php@2.14.0
+
+ - name: Set up PHP ${{ matrix.php-version }}
+ uses: shivammathur/setup-php@v2
with:
- php-version: '7.4'
- - name: Install
- run: composer install
- - name: Install
- run: composer validate
- - name: Test
+ php-version: ${{ matrix.php-version }}
+ extensions: pcntl, posix, intl
+ coverage: xdebug
+ ini-values: error_reporting=E_ALL
+
+ - name: Install dependencies
+ run: composer update
+ --prefer-dist
+ --no-progress
+ ${{ matrix.composer-options }}
+
+ - name: Run tests
+ if: ${{ matrix.php-version != '8.1' }}
run: composer test
- build80:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2.3.4
- - uses: shivammathur/setup-php@2.14.0
- with:
- php-version: '8.0'
- - name: Install
- run: composer install
- - name: Install
- run: composer validate
- - name: Test
+
+ - name: Run tests (experimental)
+ if: ${{ matrix.php-version == '8.1' }}
+ continue-on-error: true
run: composer test
diff --git a/.gitignore b/.gitignore
index 404ae0d..f1f7aef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,5 +4,6 @@ vendor/*
composer.phar
composer.lock
/build/
+/test/codeception/_support/_generated/
.phpunit.result.cache
diff --git a/README.md b/README.md
index 94ed6ce..51ef322 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,11 @@
# Allure Codeception Adapter
+[](https://packagist.org/packages/allure-framework/allure-codeception)
[](https://github.com/allure-framework/allure-codeception/actions/workflows/build.yml)
+[](https://shepherd.dev/github/allure-framework/allure-codeception)
+[](https://shepherd.dev/github/allure-framework/allure-codeception)
+[](https://packagist.org/packages/allure-framework/allure-codeception)
+[](https://packagist.org/packages/allure-framework/allure-codeception)
This is an official [Codeception](http://codeception.com) adapter for Allure Framework.
@@ -15,8 +20,8 @@ In order to use this adapter you need to add a new dependency to your **composer
```
{
"require": {
- "php": ">=5.4.0",
- "allure-framework/allure-codeception": ">=1.1.0"
+ "php": "^8",
+ "allure-framework/allure-codeception": "^2"
}
}
```
@@ -24,36 +29,27 @@ To enable this adapter in Codeception tests simply put it in "enabled" extension
```yaml
extensions:
enabled:
- - Yandex\Allure\Codeception\AllureCodeception
+ - Qameta\Allure\Codeception\AllureCodeception
config:
- Yandex\Allure\Codeception\AllureCodeception:
- deletePreviousResults: false
+ Qameta\Allure\Codeception\AllureCodeception:
outputDirectory: allure-results
- ignoredAnnotations:
- - env
- - dataprovider
```
-`deletePreviousResults` will clear all `.xml` files from output directory (this
-behavior may change to complete cleanup later). It is set to `false` by default.
-
`outputDirectory` is used to store Allure results and will be calculated
relatively to Codeception output directory (also known as `paths: log` in
codeception.yml) unless you specify an absolute path. You can traverse up using
`..` as usual. `outputDirectory` defaults to `allure-results`.
-`ignoredAnnotations` is used to define extra custom annotations to ignore. It is empty by default.
-
To generate report from your favourite terminal,
[install](https://github.com/allure-framework/allure-cli#installation)
allure-cli and run following command (assuming you're in project root and using
default configuration):
```bash
-allure generate --report-version 1.4.5 --report-path tests/_output/allure-report -- tests/_output/allure-results
+allure generate -o ./build/allure-report ./build/allure-results
```
-Report will be generated in `tests/_output/allure-report`.
+Report will be generated in `build/allure-report`.
## Main features
See respective [PHPUnit](https://github.com/allure-framework/allure-phpunit#advanced-features) section.
diff --git a/codeception.yml b/codeception.yml
index 9db5f91..72402e6 100644
--- a/codeception.yml
+++ b/codeception.yml
@@ -1,7 +1,4 @@
-namespace: Yandex\Allure\Codeception
-suites:
- unit:
- path: .
+namespace: Qameta\Allure\Codeception\Test
settings:
lint: true
@@ -9,13 +6,12 @@ paths:
tests: test/codeception
output: build
support: test/codeception/_support
- data: test/codeception
+ data: test/codeception/_data
extensions:
enabled:
- - Yandex\Allure\Codeception\AllureCodeception
+ - Qameta\Allure\Codeception\AllureCodeception
config:
- Yandex\Allure\Codeception\AllureCodeception:
- deletePreviousResults: true
+ Qameta\Allure\Codeception\AllureCodeception:
outputDirectory: allure-results
diff --git a/composer.json b/composer.json
index bf74156..f252a86 100644
--- a/composer.json
+++ b/composer.json
@@ -8,7 +8,12 @@
{
"name": "Ivan Krutov",
"email": "vania-pooh@aerokube.com",
- "role": "Developer"
+ "role": "Developer"
+ },
+ {
+ "name": "Edward Surov",
+ "email": "zoohie@gmail.com",
+ "role": "Developer"
}
],
"support": {
@@ -16,37 +21,56 @@
"source": "https://github.com/allure-framework/allure-codeception"
},
"require": {
- "php": ">=7.1.3",
+ "php": "^8",
"ext-json": "*",
- "codeception/codeception": "^2.5 | ^3 | ^4",
- "allure-framework/allure-php-api": "^1.3",
- "symfony/filesystem": "^2.7 | ^3 | ^4 | ^5",
- "symfony/finder": "^2.7 | ^3 | ^4 | ^5"
+ "codeception/codeception": "^4.1",
+ "allure-framework/allure-php-commons": "2.0.0-rc3"
},
"require-dev": {
"ext-dom": "*",
- "phpunit/phpunit": "^7.2 | ^8 | ^9"
+ "phpunit/phpunit": "^9",
+ "psalm/plugin-phpunit": "^0.16.1",
+ "remorhaz/php-json-data": "^0.5.3",
+ "remorhaz/php-json-path": "^0.7.7",
+ "squizlabs/php_codesniffer": "^3.6.1",
+ "vimeo/psalm": "^4.10"
+ },
+ "conflict": {
+ "codeception/phpunit-wrapper": "<9.0.1"
},
"autoload": {
- "psr-0": {
- "Yandex": "src/"
+ "psr-4": {
+ "Qameta\\Allure\\Codeception\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
- "Yandex\\Allure\\Codeception\\": [
- "test/report/",
- "test/unit/"
+ "Qameta\\Allure\\Codeception\\Test\\Functional\\": "test/codeception/functional/",
+ "Qameta\\Allure\\Codeception\\Test\\Acceptance\\": "test/codeception/acceptance/",
+ "Qameta\\Allure\\Codeception\\Test\\Unit\\": "test/codeception/unit/",
+ "Qameta\\Allure\\Codeception\\Test\\": [
+ "test/codeception/_support/",
+ "test/report/"
]
}
},
"scripts": {
- "test-report": [
- "vendor/bin/codecept run --no-exit --report",
- "vendor/bin/phpunit --testsuite=report"
+ "build": [
+ "vendor/bin/codecept build",
+ "vendor/bin/codecept gherkin:snippets acceptance"
+ ],
+ "test-cs": "vendor/bin/phpcs -sp",
+ "test-report-generate": [
+ "rm -rf ./build/log/",
+ "vendor/bin/codecept run --no-exit --report"
],
+ "test-report-check": "vendor/bin/phpunit --testsuite=report",
+ "test-psalm": "vendor/bin/psalm --shepherd",
"test": [
- "@test-report"
+ "@test-cs",
+ "@test-report-generate",
+ "@test-report-check",
+ "@test-psalm"
]
}
}
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
new file mode 100644
index 0000000..227a3bb
--- /dev/null
+++ b/phpcs.xml.dist
@@ -0,0 +1,15 @@
+
+
+ Qameta Coding Standards
+
+ src
+ test
+
+
+
+
+
+
+ */test/*Test.php
+
+
diff --git a/phpunit.xml b/phpunit.xml.dist
similarity index 95%
rename from phpunit.xml
rename to phpunit.xml.dist
index dcb4000..8d84520 100644
--- a/phpunit.xml
+++ b/phpunit.xml.dist
@@ -1,7 +1,7 @@
diff --git a/psalm.xml.dist b/psalm.xml.dist
new file mode 100644
index 0000000..4b5f86f
--- /dev/null
+++ b/psalm.xml.dist
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AllureCodeception.php b/src/AllureCodeception.php
new file mode 100644
index 0000000..d603690
--- /dev/null
+++ b/src/AllureCodeception.php
@@ -0,0 +1,279 @@
+ 'suiteBefore',
+ Events::SUITE_AFTER => 'suiteAfter',
+ Events::TEST_START => 'testStart',
+ Events::TEST_FAIL => 'testFail',
+ Events::TEST_ERROR => 'testError',
+ Events::TEST_INCOMPLETE => 'testIncomplete',
+ Events::TEST_SKIPPED => 'testSkipped',
+ Events::TEST_SUCCESS => 'testSuccess',
+ Events::TEST_END => 'testEnd',
+ Events::STEP_BEFORE => 'stepBefore',
+ Events::STEP_AFTER => 'stepAfter'
+ ];
+
+ /**
+ * @var array
+ */
+ private array $linkTemplates = [];
+
+ private ?ThreadDetectorInterface $threadDetector = null;
+
+ private ?TestLifecycleInterface $testLifecycle = null;
+
+ /**
+ * {@inheritDoc}
+ *
+ * @throws ConfigurationException
+ * phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
+ */
+ public function _initialize(): void
+ {
+ // phpcs:enable PSR2.Methods.MethodDeclaration.Underscore
+ parent::_initialize();
+ QametaAllure::reset();
+ QametaAllure::getLifecycleConfigurator()
+ ->setStatusDetector(new StatusDetector(new DefaultStatusDetector()));
+ $this->callSetupHook();
+ QametaAllure::setOutputDirectory($this->getOutputDirectory());
+ }
+
+ private function callSetupHook(): void
+ {
+ /**
+ * @var mixed $hookClass
+ * @psalm-var array $this->config
+ */
+ $hookClass = $this->config[self::SETUP_HOOK_PARAMETER] ?? '';
+ /** @psalm-suppress MixedMethodCall */
+ $hook = is_string($hookClass) && class_exists($hookClass)
+ ? new $hookClass()
+ : null;
+
+ if (is_callable($hook)) {
+ $hook();
+ }
+ }
+
+ /**
+ * @throws ConfigurationException
+ */
+ private function getOutputDirectory(): string
+ {
+ /**
+ * @var mixed $outputCfg
+ * @psalm-var array $this->config
+ */
+ $outputCfg = $this->config[self::OUTPUT_DIRECTORY_PARAMETER] ?? null;
+ $outputLocal = is_string($outputCfg)
+ ? trim($outputCfg, '\\/')
+ : null;
+
+ return Configuration::outputDir() . ($outputLocal ?? self::DEFAULT_RESULTS_DIRECTORY) . DIRECTORY_SEPARATOR;
+ }
+
+ /**
+ * @psalm-suppress MissingDependency
+ */
+ public function suiteBefore(SuiteEvent $suiteEvent): void
+ {
+ /** @psalm-suppress InternalMethod */
+ $suiteName = $suiteEvent->getSuite()->getName();
+ $this
+ ->getTestLifecycle()
+ ->switchToSuite(new SuiteInfo($suiteName));
+ }
+
+ public function suiteAfter(): void
+ {
+ $this
+ ->getTestLifecycle()
+ ->resetSuite();
+ }
+
+ /**
+ * @psalm-suppress MissingDependency
+ */
+ public function testStart(TestEvent $testEvent): void
+ {
+ $test = $testEvent->getTest();
+ $this
+ ->getTestLifecycle()
+ ->switchToTest($test)
+ ->create()
+ ->updateTest()
+ ->startTest();
+ }
+
+ private function getThreadDetector(): ThreadDetectorInterface
+ {
+ return $this->threadDetector ??= new DefaultThreadDetector();
+ }
+
+ /**
+ * @psalm-suppress MissingDependency
+ */
+ public function testError(FailEvent $failEvent): void
+ {
+ /** @var Throwable $error */
+ $error = $failEvent->getFail();
+ $this
+ ->getTestLifecycle()
+ ->switchToTest($failEvent->getTest())
+ ->updateTestFailure($error);
+ }
+
+ /**
+ * @psalm-suppress MissingDependency
+ */
+ public function testFail(FailEvent $failEvent): void
+ {
+ /** @var Throwable $error */
+ $error = $failEvent->getFail();
+ $this
+ ->getTestLifecycle()
+ ->switchToTest($failEvent->getTest())
+ ->updateTestFailure($error, Status::failed());
+ }
+
+ /**
+ * @psalm-suppress MissingDependency
+ */
+ public function testIncomplete(FailEvent $failEvent): void
+ {
+ /** @var Throwable $error */
+ $error = $failEvent->getFail();
+ $this
+ ->getTestLifecycle()
+ ->switchToTest($failEvent->getTest())
+ ->updateTestFailure(
+ $error,
+ Status::broken(),
+ new StatusDetails(message: $error->getMessage(), trace: $error->getTraceAsString()),
+ );
+ }
+
+ /**
+ * @psalm-suppress MissingDependency
+ */
+ public function testSkipped(FailEvent $failEvent): void
+ {
+ /** @var Throwable $error */
+ $error = $failEvent->getFail();
+ $this
+ ->getTestLifecycle()
+ ->switchToTest($failEvent->getTest())
+ ->updateTestFailure(
+ $error,
+ Status::skipped(),
+ new StatusDetails(message: $error->getMessage(), trace: $error->getTraceAsString()),
+ );
+ }
+
+ /**
+ * @psalm-suppress MissingDependency
+ */
+ public function testSuccess(TestEvent $testEvent): void
+ {
+ $this
+ ->getTestLifecycle()
+ ->switchToTest($testEvent->getTest())
+ ->updateTestSuccess();
+ }
+
+ /**
+ * @psalm-suppress MissingDependency
+ */
+ public function testEnd(TestEvent $testEvent): void
+ {
+ $this
+ ->getTestLifecycle()
+ ->switchToTest($testEvent->getTest())
+ ->updateTestResult()
+ ->attachReports()
+ ->stopTest();
+ }
+
+ /**
+ * @psalm-suppress MissingDependency
+ */
+ public function stepBefore(StepEvent $stepEvent): void
+ {
+ /** @psalm-var Step $step */
+ $step = $stepEvent->getStep();
+ $this
+ ->getTestLifecycle()
+ ->switchToTest($stepEvent->getTest())
+ ->startStep($step)
+ ->updateStep();
+ }
+
+ /**
+ * @psalm-suppress MissingDependency
+ */
+ public function stepAfter(StepEvent $stepEvent): void
+ {
+ /** @psalm-var Step $step */
+ $step = $stepEvent->getStep();
+ $this
+ ->getTestLifecycle()
+ ->switchToTest($stepEvent->getTest())
+ ->switchToStep($step)
+ ->updateStepResult()
+ ->stopStep();
+ }
+
+ private function getTestLifecycle(): TestLifecycleInterface
+ {
+ return $this->testLifecycle ??= new TestLifecycle(
+ Allure::getLifecycle(),
+ Allure::getResultFactory(),
+ Allure::getStatusDetector(),
+ $this->getThreadDetector(),
+ $this->linkTemplates,
+ );
+ }
+}
diff --git a/src/Internal/ArgumentAsString.php b/src/Internal/ArgumentAsString.php
new file mode 100644
index 0000000..bfaed8e
--- /dev/null
+++ b/src/Internal/ArgumentAsString.php
@@ -0,0 +1,92 @@
+ $this->prepareString($argument),
+ is_resource($argument) => $this->prepareResource($argument),
+ is_array($argument) => $this->prepareArray($argument),
+ is_object($argument) => $this->prepareObject($argument),
+ default => $argument,
+ };
+ }
+
+ private function prepareString(string $argument): string
+ {
+ return strtr($argument, ["\n" => '\n', "\r" => '\r', "\t" => ' ']);
+ }
+
+ /**
+ * @param resource $argument
+ * @return string
+ */
+ private function prepareResource($argument): string
+ {
+ return (string) $argument;
+ }
+
+ private function prepareArray(array $argument): array
+ {
+ return array_map(
+ fn (mixed $element): mixed => $this->prepareArgument($element),
+ $argument,
+ );
+ }
+
+ private function prepareObject(object $argument): string
+ {
+ if (isset($argument->__mocked) && is_object($argument->__mocked)) {
+ $argument = $argument->__mocked;
+ }
+ if ($argument instanceof Stringable) {
+ return (string) $argument;
+ }
+
+ if (is_a($argument, 'Facebook\WebDriver\WebDriverBy')) {
+ return Locator::humanReadableString($argument);
+ }
+
+ return trim($argument::class, "\\");
+ }
+
+ public function __toString(): string
+ {
+ return json_encode(
+ $this->prepareArgument($this->argument),
+ JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
+ );
+ }
+}
diff --git a/src/Internal/CeptInfoBuilder.php b/src/Internal/CeptInfoBuilder.php
new file mode 100644
index 0000000..8ad377d
--- /dev/null
+++ b/src/Internal/CeptInfoBuilder.php
@@ -0,0 +1,28 @@
+test,
+ signature: (string) $this->test->getSignature(),
+ class: (string) $this->test->getName(),
+ method: (string) $this->test->getName(),
+ host: $host,
+ thread: $thread,
+ );
+ }
+}
diff --git a/src/Internal/CeptProvider.php b/src/Internal/CeptProvider.php
new file mode 100644
index 0000000..a436da9
--- /dev/null
+++ b/src/Internal/CeptProvider.php
@@ -0,0 +1,204 @@
+
+ */
+ private array $legacyLabels = [];
+
+ /**
+ * @var list
+ */
+ private array $legacyLinks = [];
+
+ private ?string $legacyTitle = null;
+
+ private ?string $legacyDescription = null;
+
+ /**
+ * @param Cept $test
+ * @param array $linkTemplates
+ */
+ public function __construct(
+ private Cept $test,
+ private array $linkTemplates = [],
+ ) {
+ }
+
+ /**
+ * @param Cept $test
+ * @param array $linkTemplates
+ * @return list
+ */
+ public static function createForChain(Cept $test, array $linkTemplates): array
+ {
+ return [new self($test, $linkTemplates)];
+ }
+
+ public function getLinks(): array
+ {
+ $this->loadLegacyModels();
+
+ return $this->legacyLinks;
+ }
+
+ public function getLabels(): array
+ {
+ $this->loadLegacyModels();
+
+ return $this->legacyLabels;
+ }
+
+ public function getParameters(): array
+ {
+ return [];
+ }
+
+ /**
+ * @deprecated Please use {@see getDisplayName()} method
+ */
+ public function getTitle(): ?string
+ {
+ return $this->getDisplayName();
+ }
+
+ public function getDisplayName(): ?string
+ {
+ $this->loadLegacyModels();
+
+ if (isset($this->legacyTitle)) {
+ return $this->legacyTitle;
+ }
+
+ /** @psalm-var mixed $testName */
+ $testName = $this->test->getName();
+
+ return is_string($testName)
+ ? $testName
+ : null;
+ }
+
+ public function getDescription(): ?string
+ {
+ $this->loadLegacyModels();
+
+ return $this->legacyDescription;
+ }
+
+ public function getDescriptionHtml(): ?string
+ {
+ return null;
+ }
+
+ private function getLegacyAnnotation(string $name): ?string
+ {
+ /**
+ * @var mixed $annotations
+ * @psalm-suppress InvalidArgument
+ */
+ $annotations = $this->test->getMetadata()->getParam($name);
+ if (!is_array($annotations)) {
+ return null;
+ }
+ /** @var mixed $lastAnnotation */
+ $lastAnnotation = array_pop($annotations);
+
+ return is_string($lastAnnotation)
+ ? $this->getStringFromTagContent(trim($lastAnnotation, '()'))
+ : null;
+ }
+
+ /**
+ * @param string $name
+ * @return list
+ */
+ private function getLegacyAnnotations(string $name): array
+ {
+ /**
+ * @var mixed $annotations
+ * @psalm-suppress InvalidArgument
+ */
+ $annotations = $this->test->getMetadata()->getParam($name);
+ $stringAnnotations = is_array($annotations)
+ ? array_values(array_filter($annotations, 'is_string'))
+ : [];
+
+ return array_merge(
+ ...array_map(
+ fn (string $annotation) => $this->getStringsFromTagContent(trim($annotation, '()')),
+ $stringAnnotations,
+ ),
+ );
+ }
+
+ private function loadLegacyModels(): void
+ {
+ if ($this->isLoaded) {
+ return;
+ }
+ $this->isLoaded = true;
+
+ $this->legacyTitle = $this->getLegacyAnnotation('Title');
+ $this->legacyDescription = $this->getLegacyAnnotation('Description');
+ $this->legacyLabels = [
+ ...array_map(
+ fn (string $value): Label => Label::feature($value),
+ $this->getLegacyAnnotations('Features'),
+ ),
+ ...array_map(
+ fn (string $value): Label => Label::story($value),
+ $this->getLegacyAnnotations('Stories'),
+ ),
+ ];
+ $linkTemplate = $this->linkTemplates[(string) LinkType::issue()] ?? null;
+ $this->legacyLinks = array_map(
+ fn (string $value): Link => Link::issue($value, $linkTemplate?->buildUrl($value)),
+ $this->getLegacyAnnotations('Issues'),
+ );
+ }
+
+ private function getStringFromTagContent(string $tagContent): string
+ {
+ return str_replace('"', '', $tagContent);
+ }
+
+ /**
+ * @param string $string
+ * @return list
+ */
+ private function getStringsFromTagContent(string $string): array
+ {
+ $detected = str_replace(['{', '}', '"'], '', $string);
+
+ return explode(',', $detected);
+ }
+}
diff --git a/src/Internal/CestInfoBuilder.php b/src/Internal/CestInfoBuilder.php
new file mode 100644
index 0000000..4fa8cdf
--- /dev/null
+++ b/src/Internal/CestInfoBuilder.php
@@ -0,0 +1,53 @@
+test->getTestClass();
+ /** @var mixed $testMethod */
+ $testMethod = $this->test->getTestMethod();
+
+ return new TestInfo(
+ originalTest: $this->test,
+ signature: (string) $this->test->getSignature(),
+ class: is_object($testClass) ? $testClass::class : null,
+ method: is_string($testMethod) ? $testMethod : null,
+ dataLabel: $this->getDataLabel(),
+ host: $host,
+ thread: $thread,
+ );
+ }
+
+ private function getDataLabel(): ?string
+ {
+ /** @psalm-var mixed $index */
+ $index = $this->test->getMetadata()->getIndex();
+
+ if (is_string($index)) {
+ return $index;
+ }
+ if (is_int($index)) {
+ return "#$index";
+ }
+
+ return null;
+ }
+}
diff --git a/src/Internal/CestProvider.php b/src/Internal/CestProvider.php
new file mode 100644
index 0000000..6f52f67
--- /dev/null
+++ b/src/Internal/CestProvider.php
@@ -0,0 +1,116 @@
+ $linkTemplates
+ * @return list
+ * @throws ReflectionException
+ */
+ public static function createForChain(Cest $test, array $linkTemplates = []): array
+ {
+ /** @var mixed $testClass */
+ $testClass = $test->getTestClass();
+ /** @var mixed $testMethod */
+ $testMethod = $test->getTestMethod();
+ /** @var callable-string|null $callableTestMethod */
+ $callableTestMethod = is_string($testMethod) ? $testMethod : null;
+
+ return [
+ ...AttributeParser::createForChain(
+ classOrObject: is_object($testClass) ? $testClass : null,
+ methodOrFunction: $callableTestMethod,
+ linkTemplates: $linkTemplates,
+ ),
+ new self($test),
+ ];
+ }
+
+ public function getLinks(): array
+ {
+ return [];
+ }
+
+ public function getLabels(): array
+ {
+ return [];
+ }
+
+ public function getParameters(): array
+ {
+ /** @var mixed $currentExample */
+ $currentExample = $this
+ ->test
+ ->getMetadata()
+ ->getCurrent('example') ?? [];
+ if (!is_array($currentExample)) {
+ return [];
+ }
+
+ return array_map(
+ fn (mixed $value, int|string $name) => new Parameter(
+ is_int($name) ? "#$name" : $name,
+ ArgumentAsString::get($value),
+ ),
+ array_values($currentExample),
+ array_keys($currentExample),
+ );
+ }
+
+ /**
+ * @deprecated Please, use {@see getDisplayName()} method
+ */
+ public function getTitle(): ?string
+ {
+ return $this->getDisplayName();
+ }
+
+ public function getDisplayName(): ?string
+ {
+ /** @psalm-var mixed $displayName */
+ $displayName = $this->test->getName();
+
+ return is_string($displayName)
+ ? $displayName
+ : null;
+ }
+
+ public function getDescription(): ?string
+ {
+ return null;
+ }
+
+ public function getDescriptionHtml(): ?string
+ {
+ return null;
+ }
+}
diff --git a/src/Internal/DefaultThreadDetector.php b/src/Internal/DefaultThreadDetector.php
new file mode 100644
index 0000000..a926dba
--- /dev/null
+++ b/src/Internal/DefaultThreadDetector.php
@@ -0,0 +1,29 @@
+host ??= gethostname();
+
+ return $this->host === false
+ ? null
+ : $this->host;
+ }
+
+ public function getThread(): ?string
+ {
+ return null;
+ }
+}
diff --git a/src/Internal/GherkinInfoBuilder.php b/src/Internal/GherkinInfoBuilder.php
new file mode 100644
index 0000000..af74a4c
--- /dev/null
+++ b/src/Internal/GherkinInfoBuilder.php
@@ -0,0 +1,35 @@
+test->getFeature();
+ /** @psalm-var mixed $methodName */
+ $methodName = $this->test->getScenarioTitle();
+
+ return new TestInfo(
+ originalTest: $this->test,
+ signature: (string) $this->test->getSignature(),
+ class: is_string($className) ? $className : null,
+ method: is_string($methodName) ? $methodName : null,
+ host: $host,
+ thread: $thread,
+ );
+ }
+}
diff --git a/src/Internal/GherkinProvider.php b/src/Internal/GherkinProvider.php
new file mode 100644
index 0000000..a831c92
--- /dev/null
+++ b/src/Internal/GherkinProvider.php
@@ -0,0 +1,73 @@
+ Label::feature($value),
+ [
+ ...array_values($this->test->getFeatureNode()->getTags()),
+ ...array_values($this->test->getScenarioNode()->getTags()),
+ ],
+ );
+ }
+
+ public function getParameters(): array
+ {
+ return [];
+ }
+
+ /**
+ * @deprecated Please use {@see getDisplayName()} method
+ */
+ public function getTitle(): ?string
+ {
+ return $this->getDisplayName();
+ }
+
+ public function getDisplayName(): ?string
+ {
+ return (string) $this->test->toString();
+ }
+
+ public function getDescription(): ?string
+ {
+ return null;
+ }
+
+ public function getDescriptionHtml(): ?string
+ {
+ return null;
+ }
+}
diff --git a/src/Internal/StepStartInfo.php b/src/Internal/StepStartInfo.php
new file mode 100644
index 0000000..099afe3
--- /dev/null
+++ b/src/Internal/StepStartInfo.php
@@ -0,0 +1,27 @@
+originalStep;
+ }
+
+ public function getUuid(): string
+ {
+ return $this->uuid;
+ }
+}
diff --git a/src/Internal/SuiteInfo.php b/src/Internal/SuiteInfo.php
new file mode 100644
index 0000000..2c085fd
--- /dev/null
+++ b/src/Internal/SuiteInfo.php
@@ -0,0 +1,34 @@
+name;
+ }
+
+ /**
+ * @return class-string|null
+ */
+ public function getClass(): ?string
+ {
+ return class_exists($this->name, false)
+ ? $this->name
+ : null;
+ }
+}
diff --git a/src/Internal/SuiteProvider.php b/src/Internal/SuiteProvider.php
new file mode 100644
index 0000000..ea85d6e
--- /dev/null
+++ b/src/Internal/SuiteProvider.php
@@ -0,0 +1,84 @@
+ $linkTemplates
+ * @return list
+ * @throws ReflectionException
+ */
+ public static function createForChain(
+ ?SuiteInfo $suiteInfo,
+ array $linkTemplates,
+ ): array {
+ $providers = [new self($suiteInfo)];
+ $suiteClass = $suiteInfo?->getClass();
+
+ return isset($suiteClass)
+ ? [
+ ...$providers,
+ ...AttributeParser::createForChain(classOrObject: $suiteClass, linkTemplates: $linkTemplates),
+ ]
+ : $providers;
+ }
+
+ public function getLinks(): array
+ {
+ return [];
+ }
+
+ public function getLabels(): array
+ {
+ return [
+ Label::language(null),
+ Label::framework('codeception'),
+ Label::parentSuite($this->suiteInfo?->getName()),
+ Label::package($this->suiteInfo?->getName()),
+ ];
+ }
+
+ public function getParameters(): array
+ {
+ return [];
+ }
+
+ public function getTitle(): ?string
+ {
+ return $this->getDisplayName();
+ }
+
+ public function getDisplayName(): ?string
+ {
+ return null;
+ }
+
+ public function getDescription(): ?string
+ {
+ return null;
+ }
+
+ public function getDescriptionHtml(): ?string
+ {
+ return null;
+ }
+}
diff --git a/src/Internal/TestInfo.php b/src/Internal/TestInfo.php
new file mode 100644
index 0000000..8ceecfd
--- /dev/null
+++ b/src/Internal/TestInfo.php
@@ -0,0 +1,55 @@
+originalTest;
+ }
+
+ public function getSignature(): string
+ {
+ return $this->signature;
+ }
+
+ public function getClass(): ?string
+ {
+ return $this->class;
+ }
+
+ public function getMethod(): ?string
+ {
+ return $this->method;
+ }
+
+ public function getDataLabel(): ?string
+ {
+ return $this->dataLabel;
+ }
+
+ public function getHost(): ?string
+ {
+ return $this->host;
+ }
+
+ public function getThread(): ?string
+ {
+ return $this->thread;
+ }
+}
diff --git a/src/Internal/TestInfoBuilderInterface.php b/src/Internal/TestInfoBuilderInterface.php
new file mode 100644
index 0000000..cb29ee3
--- /dev/null
+++ b/src/Internal/TestInfoBuilderInterface.php
@@ -0,0 +1,11 @@
+
+ */
+ public static function createForChain(TestInfo $info): array
+ {
+ return [new self($info)];
+ }
+
+ public function getLinks(): array
+ {
+ return [];
+ }
+
+ public function getLabels(): array
+ {
+ return [
+ Label::testClass($this->info->getClass()),
+ Label::testMethod($this->info->getMethod()),
+ Label::host($this->info->getHost()),
+ Label::thread($this->info->getThread()),
+ ];
+ }
+
+ public function getParameters(): array
+ {
+ return [];
+ }
+
+ /**
+ * @deprecated Please use {@see getDisplayName()} method
+ */
+ public function getTitle(): ?string
+ {
+ return $this->getDisplayName();
+ }
+
+ public function getDisplayName(): ?string
+ {
+ return null;
+ }
+
+ public function getDescription(): ?string
+ {
+ return null;
+ }
+
+ public function getDescriptionHtml(): ?string
+ {
+ return null;
+ }
+}
diff --git a/src/Internal/TestLifecycle.php b/src/Internal/TestLifecycle.php
new file mode 100644
index 0000000..32eea66
--- /dev/null
+++ b/src/Internal/TestLifecycle.php
@@ -0,0 +1,385 @@
+
+ */
+ private WeakMap $stepStarts;
+
+ /**
+ * @param AllureLifecycleInterface $lifecycle
+ * @param ResultFactoryInterface $resultFactory
+ * @param StatusDetectorInterface $statusDetector
+ * @param ThreadDetectorInterface $threadDetector
+ * @param array $linkTemplates
+ */
+ public function __construct(
+ private AllureLifecycleInterface $lifecycle,
+ private ResultFactoryInterface $resultFactory,
+ private StatusDetectorInterface $statusDetector,
+ private ThreadDetectorInterface $threadDetector,
+ private array $linkTemplates = [],
+ ) {
+ /** @var WeakMap */
+ $this->stepStarts = new WeakMap();
+ }
+
+ public function getCurrentSuite(): SuiteInfo
+ {
+ return $this->currentSuite ?? throw new RuntimeException("Current suite not found");
+ }
+
+ public function getCurrentTest(): TestInfo
+ {
+ return $this->currentTest ?? throw new RuntimeException("Current test not found");
+ }
+
+ public function getCurrentTestStart(): TestStartInfo
+ {
+ return $this->currentTestStart ?? throw new RuntimeException("Current test start not found");
+ }
+
+ public function getCurrentStepStart(): StepStartInfo
+ {
+ return $this->currentStepStart ?? throw new RuntimeException("Current step start not found");
+ }
+
+ public function switchToSuite(SuiteInfo $suiteInfo): self
+ {
+ $this->currentSuite = $suiteInfo;
+
+ return $this;
+ }
+
+ public function resetSuite(): self
+ {
+ $this->currentSuite = null;
+
+ return $this;
+ }
+
+ public function switchToTest(object $test): self
+ {
+ $thread = $this->threadDetector->getThread();
+ $this->lifecycle->switchThread($thread);
+
+ $this->currentTest = $this
+ ->getTestInfoBuilder($test)
+ ->build(
+ $this->threadDetector->getHost(),
+ $thread,
+ );
+
+ return $this;
+ }
+
+ private function getTestInfoBuilder(object $test): TestInfoBuilderInterface
+ {
+ return match (true) {
+ $test instanceof Cest => new CestInfoBuilder($test),
+ $test instanceof Gherkin => new GherkinInfoBuilder($test),
+ $test instanceof Cept => new CeptInfoBuilder($test),
+ $test instanceof TestCase => new UnitInfoBuilder($test),
+ default => new UnknownInfoBuilder($test),
+ };
+ }
+
+ public function create(): self
+ {
+ $containerResult = $this->resultFactory->createContainer();
+ $this->lifecycle->startContainer($containerResult);
+
+ $testResult = $this->resultFactory->createTest();
+ $this->lifecycle->scheduleTest($testResult, $containerResult->getUuid());
+
+ $this->currentTestStart = new TestStartInfo(
+ containerUuid: $containerResult->getUuid(),
+ testUuid: $testResult->getUuid(),
+ );
+
+ return $this;
+ }
+
+ public function updateTest(): self
+ {
+ $provider = new ModelProviderChain(
+ ...SuiteProvider::createForChain($this->getCurrentSuite(), $this->linkTemplates),
+ ...TestInfoProvider::createForChain($this->getCurrentTest()),
+ ...$this->createModelProvidersForTest($this->getCurrentTest()->getOriginalTest()),
+ );
+ $this->lifecycle->updateTest(
+ fn (TestResult $t) => $t
+ ->setName($provider->getDisplayName())
+ ->setFullName($this->getCurrentTest()->getSignature())
+ ->setDescription($provider->getDescription())
+ ->setDescriptionHtml($provider->getDescriptionHtml())
+ ->addLinks(...$provider->getLinks())
+ ->addLabels(...$provider->getLabels())
+ ->addParameters(...$provider->getParameters()),
+ $this->getCurrentTestStart()->getTestUuid(),
+ );
+
+ return $this;
+ }
+
+ private function createModelProvidersForTest(mixed $test): array
+ {
+ return match (true) {
+ $test instanceof Cest => CestProvider::createForChain($test, $this->linkTemplates),
+ $test instanceof Gherkin => GherkinProvider::createForChain($test),
+ $test instanceof Cept => CeptProvider::createForChain($test, $this->linkTemplates),
+ $test instanceof TestCase => UnitProvider::createForChain($test, $this->linkTemplates),
+ default => [],
+ };
+ }
+
+ public function startTest(): self
+ {
+ $this->lifecycle->startTest($this->getCurrentTestStart()->getTestUuid());
+
+ return $this;
+ }
+
+ public function stopTest(): self
+ {
+ $testUuid = $this->getCurrentTestStart()->getTestUuid();
+ $this
+ ->lifecycle
+ ->stopTest($testUuid);
+ $this->lifecycle->writeTest($testUuid);
+
+ $containerUuid = $this->getCurrentTestStart()->getContainerUuid();
+ $this
+ ->lifecycle
+ ->stopContainer($containerUuid);
+ $this->lifecycle->writeContainer($containerUuid);
+
+ $this->currentTest = null;
+ $this->currentTestStart = null;
+
+ return $this;
+ }
+
+ public function updateTestFailure(
+ Throwable $error,
+ ?Status $status = null,
+ ?StatusDetails $statusDetails = null,
+ ): self {
+ $this->lifecycle->updateTest(
+ fn (TestResult $t) => $t
+ ->setStatus($status ?? $this->statusDetector->getStatus($error))
+ ->setStatusDetails($statusDetails ?? $this->statusDetector->getStatusDetails($error)),
+ );
+
+ return $this;
+ }
+
+ public function updateTestSuccess(): self
+ {
+ $this->lifecycle->updateTest(
+ fn (TestResult $t) => $t->setStatus(Status::passed()),
+ );
+
+ return $this;
+ }
+
+ public function attachReports(): self
+ {
+ $originalTest = $this->getCurrentTest()->getOriginalTest();
+ if ($originalTest instanceof TestInterface) {
+ $artifacts = $originalTest->getMetadata()->getReports();
+ /**
+ * @psalm-var mixed $artifact
+ */
+ foreach ($artifacts as $name => $artifact) {
+ $attachment = $this
+ ->resultFactory
+ ->createAttachment()
+ ->setName((string) $name);
+ if (!is_string($artifact)) {
+ continue;
+ }
+ $dataSource = @file_exists($artifact) && !is_file($artifact)
+ ? DataSourceFactory::fromFile($artifact)
+ : DataSourceFactory::fromString($artifact);
+ $this
+ ->lifecycle
+ ->addAttachment($attachment, $dataSource);
+ }
+ }
+
+ return $this;
+ }
+
+ public function updateTestResult(): self
+ {
+ $this->lifecycle->updateTest(
+ fn (TestResult $t) => $t
+ ->setTestCaseId($testCaseId = $this->buildTestCaseId($this->getCurrentTest(), ...$t->getParameters()))
+ ->setHistoryId($this->buildHistoryId($testCaseId, $this->getCurrentTest(), ...$t->getParameters())),
+ $this->getCurrentTestStart()->getTestUuid(),
+ );
+
+ return $this;
+ }
+
+ private function buildTestCaseId(TestInfo $testInfo, Parameter ...$parameters): string
+ {
+ $parameterNames = implode(
+ '::',
+ array_map(
+ fn (Parameter $parameter): string => $parameter->getName(),
+ array_filter(
+ $parameters,
+ fn (Parameter $parameter): bool => !$parameter->getExcluded(),
+ ),
+ ),
+ );
+
+ return md5("{$testInfo->getSignature()}::$parameterNames");
+ }
+
+ private function buildHistoryId(string $testCaseId, TestInfo $testInfo, Parameter ...$parameters): string
+ {
+ $parameterNames = implode(
+ '::',
+ array_map(
+ fn (Parameter $parameter): string => $parameter->getValue() ?? '',
+ array_filter(
+ $parameters,
+ fn (Parameter $parameter): bool => !$parameter->getExcluded(),
+ ),
+ ),
+ );
+
+ return md5("$testCaseId::{$testInfo->getSignature()}::$parameterNames");
+ }
+
+ public function startStep(Step $step): self
+ {
+ $parent = $this->currentStepStart?->getUuid() ?? $this->currentTestStart?->getTestUuid();
+ $stepResult = $this->resultFactory->createStep();
+ $this->lifecycle->startStep($stepResult, $parent);
+
+ $stepStart = new StepStartInfo(
+ $step,
+ $stepResult->getUuid(),
+ );
+ $this->stepStarts[$step] = $stepStart;
+ $this->currentStepStart = $stepStart;
+
+ return $this;
+ }
+
+ public function switchToStep(Step $step): self
+ {
+ $this->currentStepStart =
+ $this->stepStarts[$step] ?? throw new RuntimeException("Step start info not found");
+
+ return $this;
+ }
+
+ public function stopStep(): self
+ {
+ $stepStart = $this->getCurrentStepStart();
+ $this->lifecycle->stopStep($stepStart->getUuid());
+ /**
+ * @var Step $step
+ * @psalm-ignore-var
+ */
+ foreach ($this->stepStarts as $step => $storedStart) {
+ if ($storedStart === $stepStart) {
+ unset($this->stepStarts[$step]);
+ }
+ }
+ $this->currentStepStart = null;
+
+ return $this;
+ }
+
+ public function updateStep(): self
+ {
+ $stepStart = $this->getCurrentStepStart();
+ $step = $stepStart->getOriginalStep();
+ if (null === $step->getAction()) {
+ $step = $step->getMetaStep();
+ }
+
+ $params = [];
+ /**
+ * @var array-key $name
+ * @var mixed $value
+ */
+ foreach ($step->getArguments() as $name => $value) {
+ $params[] = new Parameter(
+ is_int($name) ? "#$name" : $name,
+ ArgumentAsString::get($value),
+ );
+ }
+ /** @var mixed $humanizedAction */
+ $humanizedAction = $step->getHumanizedActionWithoutArguments();
+ $this->lifecycle->updateStep(
+ fn (StepResult $s) => $s
+ ->setName(is_string($humanizedAction) ? $humanizedAction : null)
+ ->setParameters(...$params),
+ $stepStart->getUuid(),
+ );
+
+ return $this;
+ }
+
+ public function updateStepResult(): self
+ {
+ $this->lifecycle->updateStep(
+ fn (StepResult $s) => $s
+ ->setStatus(
+ $this->getCurrentStepStart()->getOriginalStep()->hasFailed()
+ ? Status::failed()
+ : Status::passed(),
+ ),
+ );
+
+ return $this;
+ }
+}
diff --git a/src/Internal/TestLifecycleInterface.php b/src/Internal/TestLifecycleInterface.php
new file mode 100644
index 0000000..9e61ed8
--- /dev/null
+++ b/src/Internal/TestLifecycleInterface.php
@@ -0,0 +1,50 @@
+containerUuid;
+ }
+
+ public function getTestUuid(): string
+ {
+ return $this->testUuid;
+ }
+}
diff --git a/src/Internal/UnitInfoBuilder.php b/src/Internal/UnitInfoBuilder.php
new file mode 100644
index 0000000..e0593c7
--- /dev/null
+++ b/src/Internal/UnitInfoBuilder.php
@@ -0,0 +1,44 @@
+test->getName(false);
+
+ return new TestInfo(
+ originalTest: $this->test,
+ signature: $this->test::class . ':' . $methodName,
+ class: $this->test::class,
+ method: $methodName,
+ dataLabel: $this->getDataLabel(),
+ host: $host,
+ thread: $thread,
+ );
+ }
+
+ private function getDataLabel(): ?string
+ {
+ /** @psalm-suppress InternalMethod */
+ $dataSet = $this->test->getDataSetAsString(false);
+
+ return 1 === preg_match('#^ with data set (.+)$#', $dataSet, $matches)
+ ? $matches[1]
+ : null;
+ }
+}
diff --git a/src/Internal/UnitProvider.php b/src/Internal/UnitProvider.php
new file mode 100644
index 0000000..d43e583
--- /dev/null
+++ b/src/Internal/UnitProvider.php
@@ -0,0 +1,126 @@
+ $linkTemplates
+ */
+ public function __construct(
+ private TestCase $test,
+ array $linkTemplates = [],
+ ) {
+ }
+
+ /**
+ * @param TestCase $test
+ * @param array $linkTemplates
+ * @throws ReflectionException
+ * @return list
+ */
+ public static function createForChain(TestCase $test, array $linkTemplates = []): array
+ {
+ /**
+ * @var callable-string|null $methodOrFunction
+ * @psalm-suppress InternalMethod
+ */
+ $methodOrFunction = $test->getName(false);
+
+ return [
+ ...AttributeParser::createForChain(
+ classOrObject: $test,
+ methodOrFunction: $methodOrFunction,
+ linkTemplates: $linkTemplates,
+ ),
+ new self($test, $linkTemplates),
+ ];
+ }
+
+ public function getLinks(): array
+ {
+ return [];
+ }
+
+ public function getLabels(): array
+ {
+ return [];
+ }
+
+ /**
+ * @throws ReflectionException
+ */
+ public function getParameters(): array
+ {
+ /** @psalm-suppress InternalMethod */
+ if (!$this->test->usesDataProvider()) {
+ return [];
+ }
+
+ $dataMethod = new ReflectionMethod($this->test, 'getProvidedData');
+ $dataMethod->setAccessible(true);
+ /** @psalm-suppress InternalMethod */
+ $methodName = $this->test->getName(false);
+ $testMethod = new ReflectionMethod($this->test, $methodName);
+ $argNames = $testMethod->getParameters();
+
+ $params = [];
+ /**
+ * @var array-key $key
+ * @var mixed $param
+ */
+ foreach ($dataMethod->invoke($this->test) as $key => $param) {
+ $argName = array_shift($argNames);
+ $name = $argName?->getName() ?? $key;
+ $params[] = new Parameter(
+ is_int($name) ? "#$name" : $name,
+ ArgumentAsString::get($param),
+ );
+ }
+
+ return $params;
+ }
+
+ /**
+ * @deprecated Please use {@see getDisplayName()} method
+ */
+ public function getTitle(): ?string
+ {
+ return $this->getDisplayName();
+ }
+
+ public function getDisplayName(): ?string
+ {
+ /** @psalm-suppress InternalMethod */
+ return $this->test->getName();
+ }
+
+ public function getDescription(): ?string
+ {
+ return null;
+ }
+
+ public function getDescriptionHtml(): ?string
+ {
+ return null;
+ }
+}
diff --git a/src/Internal/UnknownInfoBuilder.php b/src/Internal/UnknownInfoBuilder.php
new file mode 100644
index 0000000..328c7a3
--- /dev/null
+++ b/src/Internal/UnknownInfoBuilder.php
@@ -0,0 +1,24 @@
+test,
+ signature: 'Unknown test: ' . $this->test::class,
+ host: $host,
+ thread: $thread,
+ );
+ }
+}
diff --git a/src/Setup/ThreadDetectorInterface.php b/src/Setup/ThreadDetectorInterface.php
new file mode 100644
index 0000000..8643516
--- /dev/null
+++ b/src/Setup/ThreadDetectorInterface.php
@@ -0,0 +1,13 @@
+getUnwrappedStatus(
+ $this->unwrapError($error),
+ );
+ }
+
+ private function getUnwrappedStatus(Throwable $error): Status
+ {
+ return match (true) {
+ $error instanceof SkippedTestError => Status::skipped(),
+ $error instanceof AssertionFailedError => Status::failed(),
+ default => Status::broken(),
+ };
+ }
+
+ public function getStatusDetails(Throwable $error): ?StatusDetails
+ {
+ $unwrappedError = $this->unwrapError($error);
+ $unwrappedStatus = $this->getUnwrappedStatus($unwrappedError);
+
+ return match (true) {
+ Status::skipped() === $unwrappedStatus,
+ Status::failed() === $unwrappedStatus => new StatusDetails(
+ message: $error->getMessage(),
+ trace: $error->getTraceAsString(),
+ ),
+ default => $this->defaultStatusDetector->getStatusDetails($unwrappedError),
+ };
+ }
+
+ private function unwrapError(Throwable $error): Throwable
+ {
+ /** @psalm-suppress InternalMethod */
+ return $error instanceof ExceptionWrapper
+ ? $error->getOriginalException() ?? $error
+ : $error;
+ }
+
+ private function buildMessage(Throwable $error): string
+ {
+ /** @psalm-suppress InternalMethod */
+ return $error instanceof AssertionFailedError
+ ? $error->toString()
+ : $error->getMessage();
+ }
+}
diff --git a/src/Yandex/Allure/Codeception/AllureCodeception.php b/src/Yandex/Allure/Codeception/AllureCodeception.php
deleted file mode 100644
index 0c758c4..0000000
--- a/src/Yandex/Allure/Codeception/AllureCodeception.php
+++ /dev/null
@@ -1,567 +0,0 @@
- 'suiteBefore',
- Events::SUITE_AFTER => 'suiteAfter',
- Events::TEST_START => 'testStart',
- Events::TEST_FAIL => 'testFail',
- Events::TEST_ERROR => 'testError',
- Events::TEST_INCOMPLETE => 'testIncomplete',
- Events::TEST_SKIPPED => 'testSkipped',
- Events::TEST_END => 'testEnd',
- Events::STEP_BEFORE => 'stepBefore',
- Events::STEP_AFTER => 'stepAfter'
- ];
-
- /**
- * Annotations that should be ignored by the annotaions parser (especially PHPUnit annotations).
- *
- * @var array
- */
- private $ignoredAnnotations = [
- 'after', 'afterClass', 'backupGlobals', 'backupStaticAttributes', 'before', 'beforeClass',
- 'codeCoverageIgnore', 'codeCoverageIgnoreStart', 'codeCoverageIgnoreEnd', 'covers',
- 'coversDefaultClass', 'coversNothing', 'dataProvider', 'depends', 'expectedException',
- 'expectedExceptionCode', 'expectedExceptionMessage', 'group', 'large', 'medium',
- 'preserveGlobalState', 'requires', 'runTestsInSeparateProcesses', 'runInSeparateProcess',
- 'small', 'test', 'testdox', 'ticket', 'uses',
- ];
-
- /**
- * Extra annotations to ignore in addition to standard PHPUnit annotations.
- *
- * @param array $ignoredAnnotations
- */
- public function _initialize(array $ignoredAnnotations = [])
- {
- parent::_initialize();
- Annotation\AnnotationProvider::registerAnnotationNamespaces();
- // Add standard PHPUnit annotations
- Annotation\AnnotationProvider::addIgnoredAnnotations($this->ignoredAnnotations);
- // Add custom ignored annotations
- $ignoredAnnotations = $this->tryGetOption(IGNORED_ANNOTATION_PARAMETER, []);
- Annotation\AnnotationProvider::addIgnoredAnnotations($ignoredAnnotations);
- $outputDirectory = $this->getOutputDirectory();
- $deletePreviousResults =
- $this->tryGetOption(DELETE_PREVIOUS_RESULTS_PARAMETER, false);
- $this->prepareOutputDirectory($outputDirectory, $deletePreviousResults);
- if (is_null(Model\Provider::getOutputDirectory())) {
- Model\Provider::setOutputDirectory($outputDirectory);
- }
- $this->setOption(INITIALIZED_PARAMETER, true);
- }
-
- /**
- * Sets runtime option which will be live
- *
- * @param string $key
- * @param mixed $value
- */
- private function setOption($key, $value)
- {
- $config = [];
- $cursor = &$config;
- $path = ['extensions', 'config', get_class()];
- foreach ($path as $segment) {
- $cursor[$segment] = [];
- $cursor = &$cursor[$segment];
- }
- $cursor[$key] = $this->config[$key] = $value;
- Configuration::append($config);
- }
-
- /**
- * Retrieves option or returns default value.
- *
- * @param string $optionKey Configuration option key.
- * @param mixed $defaultValue Value to return in case option isn't set.
- *
- * @return mixed Option value.
- * @since 0.1.0
- */
- private function tryGetOption($optionKey, $defaultValue = null)
- {
- if (array_key_exists($optionKey, $this->config)) {
- return $this->config[$optionKey];
- }
- return $defaultValue;
- }
-
- /** @noinspection PhpUnusedPrivateMethodInspection */
- /**
- * Retrieves option or dies.
- *
- * @param string $optionKey Configuration option key.
- *
- * @throws ConfigurationException Thrown if option can't be retrieved.
- *
- * @return mixed Option value.
- * @since 0.1.0
- */
- private function getOption($optionKey)
- {
- if (!array_key_exists($optionKey, $this->config)) {
- $template = '%s: Couldn\'t find required configuration option `%s`';
- $message = sprintf($template, __CLASS__, $optionKey);
- throw new ConfigurationException($message);
- }
- return $this->config[$optionKey];
- }
-
- /**
- * Returns output directory.
- *
- * @throws ConfigurationException Thrown if there is Codeception-wide
- * problem with output directory
- * configuration.
- *
- * @return string Absolute path to output directory.
- * @since 0.1.0
- */
- private function getOutputDirectory()
- {
- $outputDirectory = $this->tryGetOption(
- OUTPUT_DIRECTORY_PARAMETER,
- DEFAULT_RESULTS_DIRECTORY
- );
- $filesystem = new Filesystem;
- if (!$filesystem->isAbsolutePath($outputDirectory)) {
- $outputDirectory = Configuration::outputDir() . $outputDirectory;
- }
- return $outputDirectory;
- }
-
- /**
- * Creates output directory (if it hasn't been created yet) and cleans it
- * up (if corresponding argument has been set to true).
- *
- * @param string $outputDirectory
- * @param bool $deletePreviousResults Whether to delete previous results
- * or keep 'em.
- *
- * @since 0.1.0
- */
- private function prepareOutputDirectory(
- $outputDirectory,
- $deletePreviousResults = false
- ) {
- $filesystem = new Filesystem;
- $filesystem->mkdir($outputDirectory, 0775);
- $initialized = $this->tryGetOption(INITIALIZED_PARAMETER, false);
- if ($deletePreviousResults && !$initialized) {
- $finder = new Finder;
- $files = $finder->files()->in($outputDirectory)->name('*.xml');
- $filesystem->remove($files);
- }
- }
-
- public function suiteBefore(SuiteEvent $suiteEvent)
- {
- $suite = $suiteEvent->getSuite();
- $suiteName = $suite->getName();
- $event = new TestSuiteStartedEvent($suiteName);
- if (class_exists($suiteName, false)) {
- $annotationManager = new Annotation\AnnotationManager(
- Annotation\AnnotationProvider::getClassAnnotations($suiteName)
- );
- $annotationManager->updateTestSuiteEvent($event);
- }
- $this->uuid = $event->getUuid();
- $this->getLifecycle()->fire($event);
- }
-
- public function suiteAfter()
- {
- $this->getLifecycle()->fire(new TestSuiteFinishedEvent($this->uuid));
- }
-
- private $testInvocations = array();
- private function buildTestName($test) {
- $testName = $test->getName();
- if ($test instanceof Cest) {
- $testFullName = get_class($test->getTestClass()) . '::' . $testName;
- if(isset($this->testInvocations[$testFullName])) {
- $this->testInvocations[$testFullName]++;
- } else {
- $this->testInvocations[$testFullName] = 0;
- }
- $currentExample = $test->getMetadata()->getCurrent();
- if ($currentExample && isset($currentExample['example']) ) {
- $testName .= ' with data set #' . $this->testInvocations[$testFullName];
- }
- } else if($test instanceof Gherkin) {
- $testName = $test->getScenarioNode()->getTitle();
- }
- return $testName;
- }
-
- public function testStart(TestEvent $testEvent)
- {
- $test = $testEvent->getTest();
- $testName = $this->buildTestName($test);
- $event = new TestCaseStartedEvent($this->uuid, $testName);
- if ($test instanceof Cest) {
- $methodName = $test->getName();
- $className = get_class($test->getTestClass());
- $event->setLabels(array_merge($event->getLabels(), [
- new Label("testMethod", $methodName),
- new Label("testClass", $className)
- ]));
- $annotations = [];
- if (class_exists($className, false)) {
- $annotations = array_merge($annotations, Annotation\AnnotationProvider::getClassAnnotations($className));
- }
- if (method_exists($className, $test->getName())){
- $annotations = array_merge($annotations, Annotation\AnnotationProvider::getMethodAnnotations($className, $test->getName()));
- }
- $annotationManager = new Annotation\AnnotationManager($annotations);
- $annotationManager->updateTestCaseEvent($event);
- } else if ($test instanceof Gherkin) {
- $featureTags = $test->getFeatureNode()->getTags();
- $scenarioTags = $test->getScenarioNode()->getTags();
- $event->setLabels(
- array_map(
- function ($a) {
- return new Label($a, LabelType::FEATURE);
- },
- array_merge($featureTags, $scenarioTags)
- )
- );
- } else if ($test instanceof Cept) {
- $annotations = $this->getCeptAnnotations($test);
- if (count($annotations) > 0) {
- $annotationManager = new Annotation\AnnotationManager($annotations);
- $annotationManager->updateTestCaseEvent($event);
- }
- } else if ($test instanceof \PHPUnit\Framework\TestCase) {
- $methodName = $this->methodName = $test->getName(false);
- $className = get_class($test);
- if (class_exists($className, false)) {
- $annotationManager = new Annotation\AnnotationManager(
- Annotation\AnnotationProvider::getClassAnnotations($className)
- );
- $annotationManager->updateTestCaseEvent($event);
- }
- if (method_exists($test, $methodName)) {
- $annotationManager = new Annotation\AnnotationManager(
- Annotation\AnnotationProvider::getMethodAnnotations(get_class($test), $methodName)
- );
- $annotationManager->updateTestCaseEvent($event);
- }
- }
- $this->getLifecycle()->fire($event);
-
- if ($test instanceof Cest) {
- $currentExample = $test->getMetadata()->getCurrent();
- if ($currentExample && isset($currentExample['example']) ) {
- foreach ($currentExample['example'] as $name => $param) {
- $paramEvent = new AddParameterEvent(
- $name, $this->stringifyArgument($param), ParameterKind::ARGUMENT);
- $this->getLifecycle()->fire($paramEvent);
- }
- }
- } else if ($test instanceof \PHPUnit_Framework_TestCase) {
- if ($test->usesDataProvider()) {
- $method = new \ReflectionMethod(get_class($test), 'getProvidedData');
- $method->setAccessible(true);
- $testMethod = new \ReflectionMethod(get_class($test), $test->getName(false));
- $paramNames = $testMethod->getParameters();
- foreach ($method->invoke($test) as $key => $param) {
- $paramName = array_shift($paramNames);
- $paramEvent = new AddParameterEvent(
- is_null($paramName)
- ? $key
- : $paramName->getName(),
- $this->stringifyArgument($param),
- ParameterKind::ARGUMENT);
- $this->getLifecycle()->fire($paramEvent);
- }
- }
- }
- }
-
- /**
- * @param FailEvent $failEvent
- */
- public function testError(FailEvent $failEvent)
- {
- $event = new TestCaseBrokenEvent();
- $e = $failEvent->getFail();
- $message = $e->getMessage();
- $this->getLifecycle()->fire($event->withException($e)->withMessage($message));
- }
-
- /**
- * @param FailEvent $failEvent
- */
- public function testFail(FailEvent $failEvent)
- {
- $event = new TestCaseFailedEvent();
- $e = $failEvent->getFail();
- $message = $e->getMessage();
- $this->getLifecycle()->fire($event->withException($e)->withMessage($message));
- }
-
- /**
- * @param FailEvent $failEvent
- */
- public function testIncomplete(FailEvent $failEvent)
- {
- $event = new TestCasePendingEvent();
- $e = $failEvent->getFail();
- $message = $e->getMessage();
- $this->getLifecycle()->fire($event->withException($e)->withMessage($message));
- }
-
- /**
- * @param FailEvent $failEvent
- */
- public function testSkipped(FailEvent $failEvent)
- {
- $event = new TestCaseCanceledEvent();
- $e = $failEvent->getFail();
- $message = $e->getMessage();
- $this->getLifecycle()->fire($event->withException($e)->withMessage($message));
- }
-
- public function testEnd(TestEvent $testEvent)
- {
- // attachments supported since Codeception 3.0
- if (version_compare(Codecept::VERSION, '3.0.0') > -1 && $testEvent->getTest() instanceof Cest) {
- $artifacts = $testEvent->getTest()->getMetadata()->getReports();
- foreach ($artifacts as $name => $artifact) {
- Allure::lifecycle()->fire(new AddAttachmentEvent($artifact, $name, null));
- }
- } elseif (version_compare(Codecept::VERSION, '3.0.0') > -1 && $testEvent->getTest() instanceof Gherkin) {
- $artifacts = $testEvent->getTest()->getMetadata()->getReports();
- foreach ($artifacts as $name => $artifact) {
- Allure::lifecycle()->fire(new AddAttachmentEvent($artifact, $name, null));
- }
- }
- $this->getLifecycle()->fire(new TestCaseFinishedEvent());
- }
-
- public function stepBefore(StepEvent $stepEvent)
- {
- $argumentsLength = $this->tryGetOption(ARGUMENTS_LENGTH, 200);
-
- $stepAction = $stepEvent->getStep()->getHumanizedActionWithoutArguments();
- $stepArgs = $stepEvent->getStep()->getArgumentsAsString($argumentsLength);
-
- if (!trim($stepAction)) {
- $stepAction = $stepEvent->getStep()->getMetaStep()->getHumanizedActionWithoutArguments();
- $stepArgs = $stepEvent->getStep()->getMetaStep()->getArgumentsAsString($argumentsLength);
- }
-
- $stepName = $stepAction . ' ' . $stepArgs;
-
- $this->emptyStep = false;
- $this->getLifecycle()->fire(new StepStartedEvent($stepName));
-}
-
- public function stepAfter(StepEvent $stepEvent)
- {
- if ($stepEvent->getStep()->hasFailed()) {
- $this->getLifecycle()->fire(new StepFailedEvent());
- }
- $this->getLifecycle()->fire(new StepFinishedEvent());
- }
-
-
- /**
- * @return Allure
- */
- public function getLifecycle()
- {
- if (!isset($this->lifecycle)){
- $this->lifecycle = Allure::lifecycle();
- }
- return $this->lifecycle;
- }
-
- public function setLifecycle(Allure $lifecycle)
- {
- $this->lifecycle = $lifecycle;
- }
-
- /**
- *
- * @param \Codeception\TestInterface $test
- * @return array
- */
- private function getCeptAnnotations($test)
- {
- $tokens = token_get_all($test->getSourceCode());
- $comments = array();
- $annotations = [];
- foreach($tokens as $token) {
- if($token[0] == T_DOC_COMMENT || $token[0] == T_COMMENT) {
- $comments[] = $token[1];
- }
- }
- foreach($comments as $comment) {
- $lines = preg_split ('/$\R?^/m', $comment);
- foreach($lines as $line) {
- $output = [];
- if (preg_match('/\*\s\@(.*)\((.*)\)/', $line, $output) > 0) {
- if ($output[1] == "Features") {
- $feature = new Features();
- $features = $this->splitAnnotationContent($output[2]);
- foreach($features as $featureName) {
- $feature->featureNames[] = $featureName;
- }
- $annotations[get_class($feature)] = $feature;
- } else if ($output[1] == 'Title') {
- $title = new Title();
- $title_content = str_replace('"', '', $output[2]);
- $title->value = $title_content;
- $annotations[get_class($title)] = $title;
- } else if ($output[1] == 'Description') {
- $description = new Description();
- $description_content = str_replace('"', '', $output[2]);
- $description->value = $description_content;
- $annotations[get_class($description)] = $description;
- } else if ($output[1] == 'Stories') {
- $stories = $this->splitAnnotationContent($output[2]);
- $story = new Stories();
- foreach($stories as $storyName) {
- $story->stories[] = $storyName;
- }
- $annotations[get_class($story)] = $story;
- } else if ($output[1] == 'Issues') {
- $issues = $this->splitAnnotationContent($output[2]);
- $issue = new Issues();
- foreach($issues as $issueName) {
- $issue->issueKeys[] = $issueName;
- }
- $annotations[get_class($issue)] = $issue;
- } else {
- Debug::debug("Tag not detected: ".$output[1]);
- }
- }
- }
- }
- return $annotations;
- }
-
- /**
- *
- * @param string $string
- * @return array
- */
- private function splitAnnotationContent($string)
- {
- $parts = [];
- $detected = str_replace('{', '', $string);
- $detected = str_replace('}', '', $detected);
- $detected = str_replace('"', '', $detected);
- $parts = explode(',', $detected);
- if (count($parts) == 0 && count($detected) > 0) {
- $parts[] = $detected;
- }
- return $parts;
- }
-
- protected function stringifyArgument($argument)
- {
- if (is_string($argument)) {
- return '"' . strtr($argument, ["\n" => '\n', "\r" => '\r', "\t" => ' ']) . '"';
- } elseif (is_resource($argument)) {
- $argument = (string)$argument;
- } elseif (is_array($argument)) {
- foreach ($argument as $key => $value) {
- if (is_object($value)) {
- $argument[$key] = $this->getClassName($value);
-}
- }
- } elseif (is_object($argument)) {
- if (method_exists($argument, '__toString')) {
- $argument = (string)$argument;
- } elseif (get_class($argument) == 'Facebook\WebDriver\WebDriverBy') {
- $argument = Locator::humanReadableString($argument);
- } else {
- $argument = $this->getClassName($argument);
- }
- }
-
- return json_encode($argument, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
- }
-
- protected function getClassName($argument)
- {
- if ($argument instanceof \Closure) {
- return 'Closure';
- } elseif ((isset($argument->__mocked))) {
- return $this->formatClassName($argument->__mocked);
- } else {
- return $this->formatClassName(get_class($argument));
- }
- }
-
- protected function formatClassName($classname)
- {
- return trim($classname, "\\");
- }
-}
diff --git a/test/codeception/AnnotationTest.php b/test/codeception/AnnotationTest.php
deleted file mode 100644
index 373be55..0000000
--- a/test/codeception/AnnotationTest.php
+++ /dev/null
@@ -1,68 +0,0 @@
-expectNotToPerformAssertions();
- }
-
- /**
- * @Description ("Test description with `markdown`", type = DescriptionType::MARKDOWN)
- */
- public function testDescriptionAnnotation(): void
- {
- $this->expectNotToPerformAssertions();
- }
-
- /**
- * @Severity (level = SeverityLevel::MINOR)
- */
- public function testSeverityAnnotation(): void
- {
- $this->expectNotToPerformAssertions();
- }
-
- /**
- * @Parameter (name = "foo", value = "bar", kind = ParameterKind::ARGUMENT)
- */
- public function testParameterAnnotation(): void
- {
- $this->expectNotToPerformAssertions();
- }
-
- /**
- * @Stories ("Story 1", "Story 2")
- */
- public function testStoriesAnnotation(): void
- {
- $this->expectNotToPerformAssertions();
- }
-
- /**
- * @Features ("Feature 1", "Feature 2")
- */
- public function testFeaturesAnnotation(): void
- {
- $this->expectNotToPerformAssertions();
- }
-}
\ No newline at end of file
diff --git a/test/codeception/_support/AcceptanceTester.php b/test/codeception/_support/AcceptanceTester.php
new file mode 100644
index 0000000..30da28d
--- /dev/null
+++ b/test/codeception/_support/AcceptanceTester.php
@@ -0,0 +1,98 @@
+
+ */
+ private array $inputs = [];
+
+ /**
+ * @var list
+ */
+ private array $outputs = [];
+
+ /**
+ * @Given I have input as :num
+ */
+ public function iHaveInputAs($num)
+ {
+ $this->inputs = [$num];
+ $this->calculate();
+ }
+
+ private function calculate(): void
+ {
+ $this->outputs = array_map(
+ fn (int $num): int => abs($num),
+ $this->inputs,
+ );
+ }
+
+ /**
+ * @Then I should get output as :num
+ */
+ public function iShouldGetOutputAs($num)
+ {
+ Assert::assertSame([(int) $num], $this->outputs);
+ }
+
+ /**
+ * @Given I have no input
+ */
+ public function iHaveNoInput()
+ {
+ $this->inputs = [];
+ $this->calculate();
+ }
+
+ /**
+ * @Given I have inputs
+ */
+ public function iHaveInputs(TableNode $table)
+ {
+ $this->inputs = array_map(
+ fn (array $row): int => (int) $row['num'],
+ iterator_to_array($table),
+ );
+ $this->calculate();
+ }
+
+ /**
+ * @Then I should get non-negative outputs
+ */
+ public function iShouldGetNonNegativeOutputs()
+ {
+ foreach ($this->outputs as $num) {
+ Assert::assertGreaterThanOrEqual(0, $num);
+ }
+ }
+}
diff --git a/test/codeception/_support/FunctionalTester.php b/test/codeception/_support/FunctionalTester.php
new file mode 100644
index 0000000..c91850c
--- /dev/null
+++ b/test/codeception/_support/FunctionalTester.php
@@ -0,0 +1,26 @@
+
+ Then I should get output as
+
+ Examples:
+ | in | out |
+ | 1 | 1 |
+ | 2 | 2 |
+
+ Scenario: various numbers
+ Given I have inputs
+ | num |
+ | -1 |
+ | 0 |
+ | 1 |
+ Then I should get non-negative outputs
\ No newline at end of file
diff --git a/test/codeception/functional.suite.yml b/test/codeception/functional.suite.yml
new file mode 100644
index 0000000..ab76c9b
--- /dev/null
+++ b/test/codeception/functional.suite.yml
@@ -0,0 +1,2 @@
+
+actor: FunctionalTester
\ No newline at end of file
diff --git a/test/codeception/functional/BasicScenarioCept.php b/test/codeception/functional/BasicScenarioCept.php
new file mode 100644
index 0000000..a4cbaaa
--- /dev/null
+++ b/test/codeception/functional/BasicScenarioCept.php
@@ -0,0 +1,12 @@
+expect('some condition');
diff --git a/test/codeception/functional/ClassTitleCest.php b/test/codeception/functional/ClassTitleCest.php
new file mode 100644
index 0000000..695d48c
--- /dev/null
+++ b/test/codeception/functional/ClassTitleCest.php
@@ -0,0 +1,18 @@
+expect('some condition');
+ }
+}
diff --git a/test/codeception/functional/CustomizedScenarioCept.php b/test/codeception/functional/CustomizedScenarioCept.php
new file mode 100644
index 0000000..d5d273c
--- /dev/null
+++ b/test/codeception/functional/CustomizedScenarioCept.php
@@ -0,0 +1,19 @@
+expect('some condition');
diff --git a/test/codeception/functional/NoClassTitleCest.php b/test/codeception/functional/NoClassTitleCest.php
new file mode 100644
index 0000000..660a6aa
--- /dev/null
+++ b/test/codeception/functional/NoClassTitleCest.php
@@ -0,0 +1,30 @@
+expect("some condition");
+ }
+
+ /**
+ * @example ["condition 1"]
+ * @example {"condition":"condition 2"}
+ */
+ public function makeActionWithExamples(FunctionalTester $I, Example $example): void
+ {
+ $I->expect($example[0] ?? $example['condition']);
+ }
+}
diff --git a/test/codeception/functional/ScenarioWithLegacyAnnotationsCept.php b/test/codeception/functional/ScenarioWithLegacyAnnotationsCept.php
new file mode 100644
index 0000000..2006db3
--- /dev/null
+++ b/test/codeception/functional/ScenarioWithLegacyAnnotationsCept.php
@@ -0,0 +1,20 @@
+expect('some condition');
diff --git a/test/codeception/_support/.gitkeep b/test/codeception/unit.suite.yml
similarity index 100%
rename from test/codeception/_support/.gitkeep
rename to test/codeception/unit.suite.yml
diff --git a/test/codeception/unit/AnnotationTest.php b/test/codeception/unit/AnnotationTest.php
new file mode 100644
index 0000000..39b6f46
--- /dev/null
+++ b/test/codeception/unit/AnnotationTest.php
@@ -0,0 +1,50 @@
+expectNotToPerformAssertions();
+ }
+
+ #[Attribute\Description('Test description with `markdown`')]
+ public function testDescriptionAnnotation(): void
+ {
+ $this->expectNotToPerformAssertions();
+ }
+
+ #[Attribute\Severity(Attribute\Severity::MINOR)]
+ public function testSeverityAnnotation(): void
+ {
+ $this->expectNotToPerformAssertions();
+ }
+
+ #[Attribute\Parameter('foo', 'bar')]
+ public function testParameterAnnotation(): void
+ {
+ $this->expectNotToPerformAssertions();
+ }
+
+ #[Attribute\Story('Story 1')]
+ #[Attribute\Story('Story 2')]
+ public function testStoriesAnnotation(): void
+ {
+ $this->expectNotToPerformAssertions();
+ }
+
+ #[Attribute\Feature('Feature 1')]
+ #[Attribute\Feature('Feature 2')]
+ public function testFeaturesAnnotation(): void
+ {
+ $this->expectNotToPerformAssertions();
+ }
+}
diff --git a/test/codeception/unit/DataProviderTest.php b/test/codeception/unit/DataProviderTest.php
new file mode 100644
index 0000000..ca4518e
--- /dev/null
+++ b/test/codeception/unit/DataProviderTest.php
@@ -0,0 +1,38 @@
+ ['foo', 'foo'],
+ 'a' => ['bar', 'bar'],
+ 'b' => ['foo', 'bar'],
+ ];
+ }
+}
diff --git a/test/codeception/StepsTest.php b/test/codeception/unit/StepsTest.php
similarity index 89%
rename from test/codeception/StepsTest.php
rename to test/codeception/unit/StepsTest.php
index 88f47d1..7f3dbb7 100644
--- a/test/codeception/StepsTest.php
+++ b/test/codeception/unit/StepsTest.php
@@ -2,14 +2,12 @@
declare(strict_types=1);
-namespace Yandex\Allure\Codeception;
+namespace Qameta\Allure\Codeception\Test\Unit;
use Codeception\Lib\ModuleContainer;
use Codeception\Scenario;
-use Codeception\Step\Assertion;
use Codeception\Step\Comment;
use Codeception\Step\Meta;
-use Codeception\Step\TryTo;
use Codeception\Test\Unit;
use Exception;
use PHPUnit\Framework\Assert;
@@ -47,6 +45,13 @@ public function testSingleSuccessfulStepWithTitle(): void
$scenario->runStep(new Comment('Step 1 name'));
}
+ public function testSingleSuccessfulStepWithArguments(): void
+ {
+ $this->expectNotToPerformAssertions();
+ $scenario = new Scenario($this);
+ $scenario->runStep(new Comment('Step 1 name', ['foo' => 'bar']));
+ }
+
public function testTwoSuccessfulSteps(): void
{
$this->expectNotToPerformAssertions();
diff --git a/test/report/ReportTest.php b/test/report/ReportTest.php
index 9e4f02f..15a2c9c 100644
--- a/test/report/ReportTest.php
+++ b/test/report/ReportTest.php
@@ -2,17 +2,24 @@
declare(strict_types=1);
-namespace Yandex\Allure\Codeception;
+namespace Qameta\Allure\Codeception\Test;
-use DOMDocument;
-use DOMXPath;
use PHPUnit\Framework\TestCase;
+use Qameta\Allure\Codeception\Test\Unit\AnnotationTest;
+use Qameta\Allure\Codeception\Test\Unit\StepsTest;
+use Remorhaz\JSON\Data\Value\EncodedJson\NodeValueFactory;
+use Remorhaz\JSON\Data\Value\NodeValueInterface;
+use Remorhaz\JSON\Path\Processor\Processor;
+use Remorhaz\JSON\Path\Processor\ProcessorInterface;
+use Remorhaz\JSON\Path\Query\QueryFactory;
+use Remorhaz\JSON\Path\Query\QueryFactoryInterface;
use RuntimeException;
+use function file_get_contents;
use function is_file;
use function pathinfo;
use function scandir;
-use function sprintf;
+use function str_ends_with;
use const DIRECTORY_SEPARATOR;
use const PATHINFO_EXTENSION;
@@ -21,243 +28,312 @@ class ReportTest extends TestCase
{
/**
- * @var string
+ * @var array>
*/
- private $buildPath;
+ private static array $testResults = [];
- /**
- * @var DOMXPath[]
- */
- private $sources = [];
+ private ?ProcessorInterface $jsonPathProcessor = null;
+
+ private ?QueryFactoryInterface $jsonPathQueryFactory = null;
- public function setUp(): void
+ public static function setUpBeforeClass(): void
{
- $this->buildPath = __DIR__ . '/../../build/allure-results';
- $files = scandir($this->buildPath);
+ $buildPath = __DIR__ . '/../../build/allure-results';
+ $files = scandir($buildPath);
+
+ $jsonValueFactory = NodeValueFactory::create();
+ $jsonPathProcessor = Processor::create();
+ $jsonPathQueryFactory = QueryFactory::create();
+ $testMethodsQuery = $jsonPathQueryFactory
+ ->createQuery('$.labels[?(@.name=="testMethod")].value');
+ $testClassesQuery = $jsonPathQueryFactory
+ ->createQuery('$.labels[?(@.name=="testClass")].value');
foreach ($files as $fileName) {
- $file = $this->buildPath . DIRECTORY_SEPARATOR . $fileName;
+ $file = $buildPath . DIRECTORY_SEPARATOR . $fileName;
if (!is_file($file)) {
continue;
}
$extension = pathinfo($file, PATHINFO_EXTENSION);
- if ('xml' == $extension) {
- $dom = new DOMDocument();
- $dom->load($file);
-
- $path = new DOMXPath($dom);
- $name = $path->query('/alr:test-suite/name')->item(0)->textContent;
- if (isset($this->sources[$name])) {
- throw new RuntimeException("Duplicate test suite: {$name}");
+ if ('json' == $extension) {
+ $fileName = pathinfo($file, PATHINFO_FILENAME);
+ if (!str_ends_with($fileName, '-result')) {
+ continue;
+ }
+ $fileContent = file_get_contents($file);
+ $data = $jsonValueFactory->createValue($fileContent);
+ /** @var mixed $class */
+ $class = $jsonPathProcessor
+ ->select($testClassesQuery, $data)
+ ->decode()[0] ?? null;
+ /** @var mixed $method */
+ $method = $jsonPathProcessor
+ ->select($testMethodsQuery, $data)
+ ->decode()[0] ?? null;
+ if (!isset($class, $method)) {
+ throw new RuntimeException("Test not found in file $file");
}
- $this->sources[$name] = $path;
+ self::assertIsString($class);
+ self::assertIsString($method);
+ self::$testResults[$class][$method] = $data;
}
}
}
/**
* @param string $class
- * @param string $xpath
+ * @param string $method
+ * @param string $jsonPath
* @param string $expectedValue
- * @dataProvider providerSingleTextNode
+ * @dataProvider providerSingleNodeValueStartsFromString
*/
- public function testSingleTextNode(string $class, string $xpath, string $expectedValue): void
+ public function testSingleNodeValueStartsFromString(
+ string $class,
+ string $method,
+ string $jsonPath,
+ string $expectedValue
+ ): void {
+ /** @psalm-var mixed $nodes */
+ $nodes = $this
+ ->getJsonPathProcessor()
+ ->select(
+ $this->getJsonPathQueryFactory()->createQuery($jsonPath),
+ self::$testResults[$class][$method]
+ ?? throw new RuntimeException("Result not found for $class::$method"),
+ )
+ ->decode();
+ self::assertIsArray($nodes);
+ self::assertCount(1, $nodes);
+ $value = $nodes[0] ?? null;
+ self::assertIsString($value);
+ self::assertStringStartsWith($expectedValue, $value);
+ }
+
+ /**
+ * @return iterable
+ */
+ public function providerSingleNodeValueStartsFromString(): iterable
{
- self::assertArrayHasKey($class, $this->sources);
- $actualValue = $this
- ->sources[$class]
- ->query($xpath)
- ->item(0)
- ->textContent;
- self::assertSame($expectedValue, $actualValue);
+ return [
+ 'Error message in test case without steps' => [
+ StepsTest::class,
+ 'testNoStepsError',
+ '$.statusDetails.message',
+ "Error\nException(0)",
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider providerExistingNodeValue
+ */
+ public function testExistingNodeValue(
+ string $class,
+ string $method,
+ string $jsonPath,
+ array $expected
+ ): void {
+ $nodes = $this
+ ->getJsonPathProcessor()
+ ->select(
+ $this->getJsonPathQueryFactory()->createQuery($jsonPath),
+ self::$testResults[$class][$method]
+ ?? throw new RuntimeException("Result not found for $class::$method"),
+ )
+ ->decode();
+ self::assertSame($expected, $nodes);
}
- public function providerSingleTextNode(): iterable
+ /**
+ * @return iterable}>
+ */
+ public function providerExistingNodeValue(): iterable
{
return [
'Test case title annotation' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testTitleAnnotation',
- '/title'
- ),
- 'Test title',
+ AnnotationTest::class,
+ 'testTitleAnnotation',
+ '$.name',
+ ['Test title'],
],
'Test case severity annotation' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testSeverityAnnotation',
- '/labels/label[@name="severity" and @value="minor"]'
- ),
- '',
+ AnnotationTest::class,
+ 'testSeverityAnnotation',
+ '$.labels[?(@.name=="severity")].value',
+ ['minor'],
],
'Test case parameter annotation' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testParameterAnnotation',
- '/parameters/parameter[@name="foo" and @value="bar" and @kind="argument"]'
- ),
- '',
- ],
- 'Test case stories annotation: first story' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testStoriesAnnotation',
- '/labels/label[@name="story" and @value="Story 1"]'
- ),
- '',
- ],
- 'Test case stories annotation: second story' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testStoriesAnnotation',
- '/labels/label[@name="story" and @value="Story 2"]'
- ),
- '',
+ AnnotationTest::class,
+ 'testParameterAnnotation',
+ '$.parameters[?(@.name=="foo")].value',
+ ['bar'],
],
- 'Test case features annotation: first feature' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testFeaturesAnnotation',
- '/labels/label[@name="feature" and @value="Feature 1"]'
- ),
- '',
+ 'Test case stories annotation' => [
+ AnnotationTest::class,
+ 'testStoriesAnnotation',
+ '$.labels[?(@.name=="story")].value',
+ ['Story 1', 'Story 2'],
],
- 'Test case features annotation: second feature' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testFeaturesAnnotation',
- '/labels/label[@name="feature" and @value="Feature 2"]'
- ),
- '',
+ 'Test case features annotation' => [
+ AnnotationTest::class,
+ 'testFeaturesAnnotation',
+ '$.labels[?(@.name=="feature")].value',
+ ['Feature 1', 'Feature 2'],
],
'Successful test case without steps' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testNoStepsSuccess',
- '[@status="passed"]/name'
- ),
+ StepsTest::class,
+ 'testNoStepsSuccess',
+ '$.status',
+ ['passed'],
+ ],
+ 'Successful test case without steps: no steps' => [
+ StepsTest::class,
'testNoStepsSuccess',
+ '$.steps[*]',
+ [],
],
'Error in test case without steps' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testNoStepsError',
- '[@status="broken"]/failure/message'
- ),
- 'Error',
+ StepsTest::class,
+ 'testNoStepsError',
+ '$.status',
+ ['broken'],
],
- 'Failure in test case without steps' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testNoStepsFailure',
- '[@status="failed"]/failure/message'
- ),
- 'Failure',
+ 'Failure message in test case without steps' => [
+ StepsTest::class,
+ 'testNoStepsFailure',
+ '$.statusDetails.message',
+ ['Failure'],
],
'Test case without steps skipped' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testNoStepsSkipped',
- '[@status="canceled"]/failure/message'
- ),
- 'Skipped',
+ StepsTest::class,
+ 'testNoStepsSkipped',
+ '$.status',
+ ['skipped'],
],
- 'Successful test case with single step: name' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testSingleSuccessfulStepWithTitle',
- '[@status="passed"]/steps/step[1][@status="passed"]/name'
- ),
- 'step 1 name ', // Codeception processes action internally
+ 'Skipped message in test case without steps' => [
+ StepsTest::class,
+ 'testNoStepsSkipped',
+ '$.statusDetails.message',
+ ['Skipped'],
],
- 'Successful test case with two successful steps: step 2 name' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testTwoSuccessfulSteps',
- '[@status="passed"]/steps/step[2][@status="passed"]/name'
- ),
- 'step 2 name ', // Codeception processes action internally
+ 'Successful test case with single step: status' => [
+ StepsTest::class,
+ 'testSingleSuccessfulStepWithTitle',
+ '$.status',
+ ['passed'],
],
- 'First step in test case with two steps fails: failure' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testTwoStepsFirstFails',
- '[@status="failed"]/failure/message'
- ),
- 'Failure',
+ 'Successful test case with single step: step status' => [
+ StepsTest::class,
+ 'testSingleSuccessfulStepWithTitle',
+ '$.steps[*].status',
+ ['passed'],
],
- 'First step in test case with two steps fails: step 1 name' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testTwoStepsFirstFails',
- '[@status="failed"]/steps/step[1][@status="failed"]/name'
- ),
- 'step 1 name ', // Codeception processes action internally
+ 'Successful test case with single step: step name' => [
+ StepsTest::class,
+ 'testSingleSuccessfulStepWithTitle',
+ '$.steps[*].name',
+ ['step 1 name'],
],
- 'Second step in test case with two steps fails: failure' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testTwoStepsSecondFails',
- '[@status="failed"]/failure/message'
- ),
- 'Failure',
+ 'Successful test case with arguments in step: status' => [
+ StepsTest::class,
+ 'testSingleSuccessfulStepWithArguments',
+ '$.status',
+ ['passed'],
],
- 'Second step in test case with two steps fails: step 1 name' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testTwoStepsSecondFails',
- '[@status="failed"]/steps/step[1][@status="passed"]/name'
- ),
- 'step 1 name ', // Codeception processes action internally
+ 'Successful test case with arguments in step: step status' => [
+ StepsTest::class,
+ 'testSingleSuccessfulStepWithArguments',
+ '$.steps[*].status',
+ ['passed'],
],
- 'Second step in test case with two steps fails: step 2 name' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testTwoStepsSecondFails',
- '[@status="failed"]/steps/step[2][@status="failed"]/name'
- ),
- 'step 2 name ', // Codeception processes action internally
+ 'Successful test case with arguments in step: step name' => [
+ StepsTest::class,
+ 'testSingleSuccessfulStepWithArguments',
+ '$.steps[*].name',
+ ['step 1 name'],
+ ],
+ 'Successful test case with arguments in step: step parameter' => [
+ StepsTest::class,
+ 'testSingleSuccessfulStepWithArguments',
+ '$.steps[*].parameters[?(@.name=="foo")].value',
+ ['"bar"'],
+ ],
+ 'Successful test case with two successful steps: status' => [
+ StepsTest::class,
+ 'testTwoSuccessfulSteps',
+ '$.status',
+ ['passed'],
+ ],
+ 'Successful test case with two successful steps: step status' => [
+ StepsTest::class,
+ 'testTwoSuccessfulSteps',
+ '$.steps[*].status',
+ ['passed', 'passed'],
+ ],
+ 'Successful test case with two successful steps: step name' => [
+ StepsTest::class,
+ 'testTwoSuccessfulSteps',
+ '$.steps[*].name',
+ ['step 1 name', 'step 2 name'],
+ ],
+ 'First step in test case with two steps fails: status' => [
+ StepsTest::class,
+ 'testTwoStepsFirstFails',
+ '$.status',
+ ['failed'],
+ ],
+ 'First step in test case with two steps fails: message' => [
+ StepsTest::class,
+ 'testTwoStepsFirstFails',
+ '$.statusDetails.message',
+ ['Failure'],
+ ],
+ 'First step in test case with two steps fails: step status' => [
+ StepsTest::class,
+ 'testTwoStepsFirstFails',
+ '$.steps[*].status',
+ ['failed'],
+ ],
+ 'First step in test case with two steps fails: step name' => [
+ StepsTest::class,
+ 'testTwoStepsFirstFails',
+ '$.steps[*].name',
+ ['step 1 name'],
+ ],
+ 'Second step in test case with two steps fails: status' => [
+ StepsTest::class,
+ 'testTwoStepsSecondFails',
+ '$.status',
+ ['failed'],
+ ],
+ 'Second step in test case with two steps fails: message' => [
+ StepsTest::class,
+ 'testTwoStepsSecondFails',
+ '$.statusDetails.message',
+ ['Failure'],
+ ],
+ 'Second step in test case with two steps fails: step status' => [
+ StepsTest::class,
+ 'testTwoStepsSecondFails',
+ '$.steps[*].status',
+ ['passed', 'failed'],
+ ],
+ 'Second step in test case with two steps fails: step name' => [
+ StepsTest::class,
+ 'testTwoStepsSecondFails',
+ '$.steps[*].name',
+ ['step 1 name', 'step 2 name'],
],
];
}
- /**
- * @param string $class
- * @param string $xpath
- * @dataProvider providerNodeNotExists
- */
- public function testNodeNotExists(string $class, string $xpath): void
- {
- $testNode = $this
- ->sources[$class]
- ->query($xpath)
- ->item(0);
- self::assertNull($testNode);
- }
-
- public function providerNodeNotExists(): iterable
+ private function getJsonPathProcessor(): ProcessorInterface
{
- return [
- 'Successful test case without steps: no steps' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testNoStepsSuccess',
- '/steps'
- )
- ],
- 'First step fails in test case with two steps: no second step' => [
- 'Yandex\Allure\Codeception.unit',
- $this->buildTestXPath(
- 'testTwoStepsFirstFails',
- '/steps/step[2]'
- )
- ],
- ];
+ return $this->jsonPathProcessor ??= Processor::create();
}
- private function buildTestXPath(string $testName, string $tail): string
+ private function getJsonPathQueryFactory(): QueryFactoryInterface
{
- return sprintf('/alr:test-suite/test-cases/test-case[name="%s"]%s', $testName, $tail);
+ return $this->jsonPathQueryFactory ??= QueryFactory::create();
}
}