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/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..dd84ea7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,38 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Desktop (please complete the following information):**
+ - OS: [e.g. iOS]
+ - Browser [e.g. chrome, safari]
+ - Version [e.g. 22]
+
+**Smartphone (please complete the following information):**
+ - Device: [e.g. iPhone6]
+ - OS: [e.g. iOS8.1]
+ - Browser [e.g. stock browser, safari]
+ - Version [e.g. 22]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..bbcbbe7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..95bc24b
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,15 @@
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ labels:
+ - "type:dependencies"
+
+ - package-ecosystem: "composer"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ labels:
+ - "type:dependencies"
diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 0000000..c382d69
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,22 @@
+# release.yml
+
+changelog:
+ categories:
+ - title: 'š New Features'
+ labels:
+ - 'type:new feature'
+ - title: 'š¬ Improvements'
+ labels:
+ - 'type:improvement'
+ - title: 'š Bug Fixes'
+ labels:
+ - 'type:bug'
+ - title: 'ā¬ļø Dependency Updates'
+ labels:
+ - 'type:dependencies'
+ - title: 'āļø Security'
+ labels:
+ - 'type:security'
+ - title: 'š» Internal changes'
+ labels:
+ - 'type:internal'
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..7326fda
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,53 @@
+name: Build
+
+on:
+ pull_request:
+ branches:
+ - '*'
+ push:
+ branches:
+ - 'main'
+ - 'hotfix-*'
+
+jobs:
+ 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"
+ - "8.2"
+ - "8.3"
+ os:
+ - ubuntu-latest
+ - windows-latest
+ - macOS-latest
+ composer-options:
+ - ""
+ - "--prefer-lowest"
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up PHP ${{ matrix.php-version }}
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ extensions: pcntl, posix, intl
+ coverage: xdebug
+ ini-values: error_reporting=E_ALL
+
+ - name: Validate composer.json and composer.lock
+ run: composer validate
+
+ - name: Install dependencies
+ run: composer update
+ --prefer-dist
+ --no-progress
+ ${{ matrix.composer-options }}
+
+ - name: Run tests
+ run: composer test
diff --git a/.github/workflows/labels-verify.yml b/.github/workflows/labels-verify.yml
new file mode 100644
index 0000000..2c3bd2a
--- /dev/null
+++ b/.github/workflows/labels-verify.yml
@@ -0,0 +1,13 @@
+name: "Verify type labels"
+
+on:
+ pull_request:
+ types: [opened, labeled, unlabeled, synchronize]
+
+jobs:
+ triage:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: baev/match-label-action@master
+ with:
+ allowed: 'type:bug,type:new feature,type:improvement,type:dependencies,type:internal,type:invalid'
diff --git a/.gitignore b/.gitignore
index 7461c28..f114c38 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,7 @@
vendor/*
composer.phar
composer.lock
+/build/
+/test/codeception*/_support/_generated/
+.phpunit.result.cache
+
diff --git a/LICENSE b/LICENSE
index d472d8c..244d7f8 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,13 +1,201 @@
-Copyright 2014 Yandex LLC
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-http://www.apache.org/licenses/LICENSE-2.0
+ 1. Definitions.
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
\ No newline at end of file
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2022 Qameta Software OĆ
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
index 50fa9b5..9d5abdf 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,12 @@
# 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.
## What is this for?
@@ -13,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"
}
}
```
@@ -22,25 +29,43 @@ 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
+ linkTemplates:
+ issue: https://example.org/issues/%s
+ setupHook: My\SetupHook
```
-`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.
+`linkTemplates` is used to process links and generate URLs for them. You can put
+here an `sprintf()`-like template or a name of class to be constructed; such class
+must implement `Qameta\Allure\Setup\LinkTemplateInterface`.
+
+`setupHook` allows to execute some bootstrapping code during initialization. You can
+put here a name of the class that implements magic `__invoke()` method - and that method
+will be called. For example, it can be used to ignore unnecessary docblock annotations:
+
+```php
+=5.6",
- "codeception/codeception": "^2.3|^3.0",
- "allure-framework/allure-php-api": "~1.1.7",
- "symfony/filesystem": ">=2.6",
- "symfony/finder": ">=2.6"
+ "php": "^8",
+ "ext-json": "*",
+ "codeception/codeception": "^5.0.3",
+ "allure-framework/allure-php-commons": "^2.3.1"
+ },
+ "require-dev": {
+ "psalm/plugin-phpunit": "^0.19.0",
+ "remorhaz/php-json-data": "^0.5.3",
+ "remorhaz/php-json-path": "^0.7.7",
+ "squizlabs/php_codesniffer": "^3.7.2",
+ "vimeo/psalm": "^5.12"
},
"autoload": {
- "psr-0": {
- "Yandex": "src/"
+ "psr-4": {
+ "Qameta\\Allure\\Codeception\\": "src/"
}
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Qameta\\Allure\\Codeception\\Test\\": "test/codeception/_support/",
+ "Qameta\\Allure\\Codeception\\Test\\Unit\\": [
+ "test/codeception/report-check/",
+ "test/codeception/unit/"
+ ],
+ "Qameta\\Allure\\Codeception\\Test\\Report\\": "test/codeception-report/_support/",
+ "Qameta\\Allure\\Codeception\\Test\\Report\\Functional\\": "test/codeception-report/functional/",
+ "Qameta\\Allure\\Codeception\\Test\\Report\\Acceptance\\": "test/codeception-report/acceptance/",
+ "Qameta\\Allure\\Codeception\\Test\\Report\\Unit\\": "test/codeception-report/unit/"
+ }
+ },
+ "scripts": {
+ "build": [
+ "vendor/bin/codecept build",
+ "vendor/bin/codecept build -c codeception-report.yml",
+ "vendor/bin/codecept gherkin:snippets acceptance -c codeception-report.yml"
+ ],
+ "test-cs": "vendor/bin/phpcs -sp",
+ "test-unit": "vendor/bin/codecept run unit --coverage-text",
+ "test-report-generate": [
+ "rm -rf ./build/allure-results/",
+ "vendor/bin/codecept run -c codeception-report.yml --no-exit --report"
+ ],
+ "test-report-check": "vendor/bin/codecept run report-check",
+ "test-psalm": "vendor/bin/psalm --shepherd",
+ "test": [
+ "@test-cs",
+ "@test-unit",
+ "@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/psalm.xml.dist b/psalm.xml.dist
new file mode 100644
index 0000000..b305a85
--- /dev/null
+++ b/psalm.xml.dist
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AllureCodeception.php b/src/AllureCodeception.php
new file mode 100644
index 0000000..ba9dbf4
--- /dev/null
+++ b/src/AllureCodeception.php
@@ -0,0 +1,321 @@
+ 'moduleInit',
+ Events::SUITE_BEFORE => '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'
+ ];
+
+ private ?ThreadDetectorInterface $threadDetector = null;
+
+ private ?TestLifecycleInterface $testLifecycle = null;
+
+ /**
+ * {@inheritDoc}
+ *
+ * @throws ConfigurationException
+ * phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
+ */
+ public function _initialize(): void
+ {
+ parent::_initialize();
+ $this->reconfigure();
+ }
+
+ /**
+ * @throws ConfigurationException
+ */
+ public function moduleInit(): void
+ {
+ $this->reconfigure();
+ }
+
+ private function reconfigure(): void
+ {
+ QametaAllure::reset();
+ $this->testLifecycle = null;
+ $this->threadDetector = null;
+ QametaAllure::getLifecycleConfigurator()
+ ->setOutputDirectory($this->getOutputDirectory());
+ foreach ($this->getLinkTemplates() as $linkType => $linkTemplate) {
+ QametaAllure::getLifecycleConfigurator()->addLinkTemplate($linkType, $linkTemplate);
+ }
+ $this->callSetupHook();
+ }
+
+ 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 MoreSpecificReturnType
+ * @return iterable
+ */
+ private function getLinkTemplates(): iterable
+ {
+ /**
+ * @var mixed $templatesConfig
+ * @psalm-var array $this->config
+ */
+ $templatesConfig = $this->config[self::LINK_TEMPLATES_PARAMETER] ?? [];
+ if (!is_array($templatesConfig)) {
+ $templatesConfig = [];
+ }
+ foreach ($templatesConfig as $linkTypeName => $linkConfig) {
+ if (!is_string($linkConfig) || !is_string($linkTypeName)) {
+ continue;
+ }
+ yield LinkType::fromOptionalString($linkTypeName) =>
+ class_exists($linkConfig) && is_a($linkConfig, LinkTemplateInterface::class, true)
+ ? new $linkConfig()
+ : new LinkTemplate($linkConfig);
+ }
+ }
+
+ /**
+ * @psalm-suppress MissingDependency
+ */
+ public function suiteBefore(SuiteEvent $suiteEvent): void
+ {
+ /** @psalm-suppress InternalMethod */
+ $suiteName = $suiteEvent->getSuite()?->getName();
+ if (!isset($suiteName)) {
+ return;
+ }
+
+ $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
+ {
+ $this
+ ->getTestLifecycle()
+ ->switchToTest($failEvent->getTest())
+ ->updateTestFailure(
+ $failEvent->getFail(),
+ Status::broken(),
+ );
+ }
+
+ /**
+ * @psalm-suppress MissingDependency
+ */
+ public function testFail(FailEvent $failEvent): void
+ {
+ $error = $failEvent->getFail();
+ $this
+ ->getTestLifecycle()
+ ->switchToTest($failEvent->getTest())
+ ->updateTestFailure(
+ $failEvent->getFail(),
+ Status::failed(),
+ new StatusDetails(message: $error->getMessage(), trace: $error->getTraceAsString()),
+ );
+ }
+
+ /**
+ * @psalm-suppress MissingDependency
+ */
+ public function testIncomplete(FailEvent $failEvent): void
+ {
+ $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
+ {
+ $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
+ {
+ $this
+ ->getTestLifecycle()
+ ->switchToTest($stepEvent->getTest())
+ ->startStep($stepEvent->getStep())
+ ->updateStep();
+ }
+
+ /**
+ * @psalm-suppress MissingDependency
+ */
+ public function stepAfter(StepEvent $stepEvent): void
+ {
+ $this
+ ->getTestLifecycle()
+ ->switchToTest($stepEvent->getTest())
+ ->switchToStep($stepEvent->getStep())
+ ->updateStepResult()
+ ->stopStep();
+ }
+
+ private function getTestLifecycle(): TestLifecycleInterface
+ {
+ return $this->testLifecycle ??= new TestLifecycle(
+ Allure::getLifecycle(),
+ Allure::getConfig()->getResultFactory(),
+ Allure::getConfig()->getStatusDetector(),
+ $this->getThreadDetector(),
+ Allure::getConfig()->getLinkTemplates(),
+ $_ENV,
+ );
+ }
+}
diff --git a/src/Internal/ArgumentAsString.php b/src/Internal/ArgumentAsString.php
new file mode 100644
index 0000000..6e2fee1
--- /dev/null
+++ b/src/Internal/ArgumentAsString.php
@@ -0,0 +1,115 @@
+ $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 isClosure(object $argument): bool
+ {
+ return $argument instanceof \Closure;
+ }
+
+ private function prepareObject(object $argument): string
+ {
+ if (!$this->isClosure($argument) && isset($argument->__mocked) && is_object($argument->__mocked)) {
+ $argument = $argument->__mocked;
+ }
+ if ($argument instanceof Stringable) {
+ return (string) $argument;
+ }
+ $webdriverByClass = '\Facebook\WebDriver\WebDriverBy';
+ if (class_exists($webdriverByClass) && is_a($argument, $webdriverByClass)) {
+ return $this->webDriverByAsString($argument);
+ }
+
+ return trim($argument::class, "\\");
+ }
+
+ public function __toString(): string
+ {
+ return json_encode(
+ $this->prepareArgument($this->argument),
+ JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
+ );
+ }
+
+ private function webDriverByAsString(object $selector): string
+ {
+ $type = method_exists($selector, 'getMechanism')
+ ? (string) $selector->getMechanism()
+ : null;
+
+ $locator = method_exists($selector, 'getValue')
+ ? (string) $selector->getValue()
+ : null;
+
+ if (!isset($type, $locator)) {
+ throw new InvalidArgumentException("Unrecognized selector");
+ }
+
+ return "$type '$locator'";
+ }
+}
diff --git a/src/Internal/CeptInfoBuilder.php b/src/Internal/CeptInfoBuilder.php
new file mode 100644
index 0000000..559fa6d
--- /dev/null
+++ b/src/Internal/CeptInfoBuilder.php
@@ -0,0 +1,27 @@
+test,
+ signature: $this->test->getSignature(),
+ class: $this->test->getName(),
+ method: $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..b98869d
--- /dev/null
+++ b/src/Internal/CeptProvider.php
@@ -0,0 +1,200 @@
+
+ */
+ private array $legacyLabels = [];
+
+ /**
+ * @var list
+ */
+ private array $legacyLinks = [];
+
+ private ?string $legacyTitle = null;
+
+ private ?string $legacyDescription = null;
+
+ /**
+ * @param Cept $test
+ * @param LinkTemplateCollectionInterface $linkTemplates
+ */
+ public function __construct(
+ private Cept $test,
+ private LinkTemplateCollectionInterface $linkTemplates,
+ ) {
+ }
+
+ /**
+ * @param Cept $test
+ * @param LinkTemplateCollectionInterface $linkTemplates
+ * @return list
+ */
+ public static function createForChain(Cept $test, LinkTemplateCollectionInterface $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 [];
+ }
+
+ 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 getFullName(): ?string
+ {
+ return $this->test->getSignature();
+ }
+
+ public function getDescription(): ?string
+ {
+ $this->loadLegacyModels();
+
+ return $this->legacyDescription;
+ }
+
+ public function getDescriptionHtml(): ?string
+ {
+ return null;
+ }
+
+ private function getLegacyAnnotation(string $name): ?string
+ {
+ /**
+ * @psalm-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
+ {
+ /**
+ * @psalm-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->get(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..1b93087
--- /dev/null
+++ b/src/Internal/CestInfoBuilder.php
@@ -0,0 +1,46 @@
+test,
+ signature: $this->test->getSignature(),
+ class: $this->test->getTestInstance()::class,
+ method: $this->test->getTestMethod(),
+ 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..6a25730
--- /dev/null
+++ b/src/Internal/CestProvider.php
@@ -0,0 +1,111 @@
+
+ * @throws ReflectionException
+ */
+ public static function createForChain(Cest $test, LinkTemplateCollectionInterface $linkTemplates): array
+ {
+ /** @psalm-var callable-string $callableTestMethod */
+ $callableTestMethod = $test->getTestMethod();
+
+ return [
+ ...AttributeParser::createForChain(
+ classOrObject: $test->getTestInstance(),
+ 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),
+ );
+ }
+
+ 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;
+ }
+
+ /**
+ * @psalm-suppress MixedOperand
+ * @psalm-suppress MixedArgument
+ */
+ public function getFullName(): ?string
+ {
+ return $this->test->getTestInstance()::class . "::" . $this->test->getTestMethod();
+ }
+}
diff --git a/src/Internal/DefaultThreadDetector.php b/src/Internal/DefaultThreadDetector.php
new file mode 100644
index 0000000..06bbd63
--- /dev/null
+++ b/src/Internal/DefaultThreadDetector.php
@@ -0,0 +1,28 @@
+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..cc744ca
--- /dev/null
+++ b/src/Internal/GherkinInfoBuilder.php
@@ -0,0 +1,29 @@
+test,
+ signature: $this->test->getSignature(),
+ class: $this->test->getFeature(),
+ method: $this->test->getScenarioTitle(),
+ host: $host,
+ thread: $thread,
+ );
+ }
+}
diff --git a/src/Internal/GherkinProvider.php b/src/Internal/GherkinProvider.php
new file mode 100644
index 0000000..86a8345
--- /dev/null
+++ b/src/Internal/GherkinProvider.php
@@ -0,0 +1,69 @@
+ Label::feature($value),
+ [
+ ...array_values($this->test->getFeatureNode()->getTags()),
+ ...array_values($this->test->getScenarioNode()->getTags()),
+ ],
+ );
+ }
+
+ public function getParameters(): array
+ {
+ return [];
+ }
+
+ public function getDisplayName(): ?string
+ {
+ return $this->test->toString();
+ }
+
+ public function getDescription(): ?string
+ {
+ return null;
+ }
+
+ public function getDescriptionHtml(): ?string
+ {
+ return null;
+ }
+
+ public function getFullName(): ?string
+ {
+ return null;
+ }
+}
diff --git a/src/Internal/StepStartInfo.php b/src/Internal/StepStartInfo.php
new file mode 100644
index 0000000..fb3f016
--- /dev/null
+++ b/src/Internal/StepStartInfo.php
@@ -0,0 +1,26 @@
+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..5ac916d
--- /dev/null
+++ b/src/Internal/SuiteInfo.php
@@ -0,0 +1,33 @@
+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..5b6a01f
--- /dev/null
+++ b/src/Internal/SuiteProvider.php
@@ -0,0 +1,83 @@
+
+ * @throws ReflectionException
+ */
+ public static function createForChain(
+ ?SuiteInfo $suiteInfo,
+ LinkTemplateCollectionInterface $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 getDisplayName(): ?string
+ {
+ return null;
+ }
+
+ public function getDescription(): ?string
+ {
+ return null;
+ }
+
+ public function getDescriptionHtml(): ?string
+ {
+ return null;
+ }
+
+ public function getFullName(): ?string
+ {
+ return null;
+ }
+}
diff --git a/src/Internal/TestInfo.php b/src/Internal/TestInfo.php
new file mode 100644
index 0000000..2c86a98
--- /dev/null
+++ b/src/Internal/TestInfo.php
@@ -0,0 +1,54 @@
+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..4b714aa
--- /dev/null
+++ b/src/Internal/TestInfoBuilderInterface.php
@@ -0,0 +1,10 @@
+
+ */
+ 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 [];
+ }
+
+ public function getDisplayName(): ?string
+ {
+ return null;
+ }
+
+ public function getDescription(): ?string
+ {
+ return null;
+ }
+
+ public function getDescriptionHtml(): ?string
+ {
+ return null;
+ }
+
+ public function getFullName(): ?string
+ {
+ return null;
+ }
+}
diff --git a/src/Internal/TestLifecycle.php b/src/Internal/TestLifecycle.php
new file mode 100644
index 0000000..725524a
--- /dev/null
+++ b/src/Internal/TestLifecycle.php
@@ -0,0 +1,373 @@
+
+ */
+ private WeakMap $stepStarts;
+
+ public function __construct(
+ private AllureLifecycleInterface $lifecycle,
+ private ResultFactoryInterface $resultFactory,
+ private StatusDetectorInterface $statusDetector,
+ private ThreadDetectorInterface $threadDetector,
+ private LinkTemplateCollectionInterface $linkTemplates,
+ private array $env,
+ ) {
+ /** @psalm-var WeakMap $this->stepStarts */
+ $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 TestCaseWrapper => 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(
+ new EnvProvider($this->env),
+ ...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($provider->getFullName())
+ ->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 TestCaseWrapper => 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
+ {
+ $stepResult = $this->resultFactory->createStep();
+ $this->lifecycle->startStep($stepResult);
+
+ $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());
+ /**
+ * @psalm-var Step $step
+ * @psalm-var StepStartInfo $storedStart
+ */
+ 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();
+
+ $params = [];
+ /** @psalm-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..6d4d8f5
--- /dev/null
+++ b/src/Internal/TestLifecycleInterface.php
@@ -0,0 +1,49 @@
+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..61dfae9
--- /dev/null
+++ b/src/Internal/UnitInfoBuilder.php
@@ -0,0 +1,35 @@
+test->getReportFields();
+ $index = $this->test->getMetadata()->getIndex();
+ $dataLabel = is_int($index) ? "#$index" : $index;
+
+ return new TestInfo(
+ originalTest: $this->test,
+ signature: $this->test->getSignature(),
+ class: $fields['class'] ?? null,
+ method: $this->test->getMetadata()->getName(),
+ dataLabel: $dataLabel,
+ host: $host,
+ thread: $thread,
+ );
+ }
+}
diff --git a/src/Internal/UnitProvider.php b/src/Internal/UnitProvider.php
new file mode 100644
index 0000000..82939a2
--- /dev/null
+++ b/src/Internal/UnitProvider.php
@@ -0,0 +1,122 @@
+
+ */
+ public static function createForChain(TestCaseWrapper $test, LinkTemplateCollectionInterface $linkTemplates): array
+ {
+ $fields = $test->getReportFields();
+ /** @var class-string $class */
+ $class = $fields['class'] ?? null;
+ /** @var Closure|callable-string|null $methodOrFunction */
+ $methodOrFunction = $test->getMetadata()->getName();
+
+ return [
+ ...AttributeParser::createForChain(
+ classOrObject: $class,
+ methodOrFunction: $methodOrFunction,
+ linkTemplates: $linkTemplates,
+ ),
+ new self($test, $linkTemplates),
+ ];
+ }
+
+ public function getLinks(): array
+ {
+ return [];
+ }
+
+ public function getLabels(): array
+ {
+ return [];
+ }
+
+ /**
+ * @throws ReflectionException
+ */
+ public function getParameters(): array
+ {
+ $testMetadata = $this->test->getMetadata();
+ if (null === $testMetadata->getIndex()) {
+ return [];
+ }
+
+ $testCase = $this->test->getTestCase();
+
+ $dataMethod = new ReflectionMethod($testCase, 'getProvidedData');
+ $dataMethod->setAccessible(true);
+ $methodName = $testMetadata->getName();
+ $testMethod = new ReflectionMethod($testCase, $methodName);
+ $argNames = $testMethod->getParameters();
+
+ $params = [];
+ /**
+ * @var array-key $key
+ * @var mixed $param
+ */
+ foreach ($dataMethod->invoke($testCase) as $key => $param) {
+ $argName = array_shift($argNames);
+ $name = $argName?->getName() ?? $key;
+ $params[] = new Parameter(
+ is_int($name) ? "#$name" : $name,
+ ArgumentAsString::get($param),
+ );
+ }
+
+ return $params;
+ }
+
+ public function getDisplayName(): ?string
+ {
+ return $this->test->getMetadata()->getName();
+ }
+
+ public function getDescription(): ?string
+ {
+ return null;
+ }
+
+ public function getDescriptionHtml(): ?string
+ {
+ return null;
+ }
+
+ public function getFullName(): ?string
+ {
+ return $this->test->getTestCase()::class . '::' . $this->test->getMetadata()->getName();
+ }
+}
diff --git a/src/Internal/UnknownInfoBuilder.php b/src/Internal/UnknownInfoBuilder.php
new file mode 100644
index 0000000..9944612
--- /dev/null
+++ b/src/Internal/UnknownInfoBuilder.php
@@ -0,0 +1,23 @@
+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..1be27ea
--- /dev/null
+++ b/src/Setup/ThreadDetectorInterface.php
@@ -0,0 +1,12 @@
+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 0edbca5..0000000
--- a/src/Yandex/Allure/Codeception/AllureCodeception.php
+++ /dev/null
@@ -1,561 +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->getMetadata()->getFeature();
- }
- 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();
- $testCaseStorage = $this->getLifecycle()->getTestCaseStorage()->get();
- foreach ($artifacts as $name => $artifact) {
- $testCaseStorage->addAttachment(new Attachment($name, $artifact, 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;
-
- //Workaround for https://github.com/allure-framework/allure-core/issues/442
- $stepName = str_replace('.', 'ā¢', $stepName);
-
- $this->emptyStep = false;
- $this->getLifecycle()->fire(new StepStartedEvent($stepName));
-}
-
- public function stepAfter()
- {
- $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-report/_support/AcceptanceTester.php b/test/codeception-report/_support/AcceptanceTester.php
new file mode 100644
index 0000000..36975a6
--- /dev/null
+++ b/test/codeception-report/_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)
+ {
+ Unit::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) {
+ Unit::assertGreaterThanOrEqual(0, $num);
+ }
+ }
+}
diff --git a/test/codeception-report/_support/FunctionalTester.php b/test/codeception-report/_support/FunctionalTester.php
new file mode 100644
index 0000000..8c64011
--- /dev/null
+++ b/test/codeception-report/_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-report/functional.suite.yml b/test/codeception-report/functional.suite.yml
new file mode 100644
index 0000000..ab76c9b
--- /dev/null
+++ b/test/codeception-report/functional.suite.yml
@@ -0,0 +1,2 @@
+
+actor: FunctionalTester
\ No newline at end of file
diff --git a/test/codeception-report/functional/BasicScenarioCept.php b/test/codeception-report/functional/BasicScenarioCept.php
new file mode 100644
index 0000000..39af9d9
--- /dev/null
+++ b/test/codeception-report/functional/BasicScenarioCept.php
@@ -0,0 +1,12 @@
+expect('some condition');
diff --git a/test/codeception-report/functional/ClassTitleCest.php b/test/codeception-report/functional/ClassTitleCest.php
new file mode 100644
index 0000000..e586e4f
--- /dev/null
+++ b/test/codeception-report/functional/ClassTitleCest.php
@@ -0,0 +1,17 @@
+expect('some condition');
+ }
+}
diff --git a/test/codeception-report/functional/CustomizedScenarioCept.php b/test/codeception-report/functional/CustomizedScenarioCept.php
new file mode 100644
index 0000000..de0d075
--- /dev/null
+++ b/test/codeception-report/functional/CustomizedScenarioCept.php
@@ -0,0 +1,19 @@
+expect('some condition');
diff --git a/test/codeception-report/functional/NestedStepsCest.php b/test/codeception-report/functional/NestedStepsCest.php
new file mode 100644
index 0000000..c759cec
--- /dev/null
+++ b/test/codeception-report/functional/NestedStepsCest.php
@@ -0,0 +1,41 @@
+expect("condition 1");
+ Allure::runStep(
+ function () use ($I): void {
+ $I->expect("condition 1.1");
+ Allure::runStep(
+ function () use ($I): void {
+ $I->expect("condition 1.1.1");
+ },
+ 'Step 1.1.1',
+ );
+ },
+ 'Step 1.1',
+ );
+ Allure::runStep(
+ function () use ($I): void {
+ $I->expect("condition 1.2");
+ },
+ 'Step 1.2',
+ );
+ },
+ 'Step 1',
+ );
+ }
+}
diff --git a/test/codeception-report/functional/NoClassTitleCest.php b/test/codeception-report/functional/NoClassTitleCest.php
new file mode 100644
index 0000000..0969b03
--- /dev/null
+++ b/test/codeception-report/functional/NoClassTitleCest.php
@@ -0,0 +1,29 @@
+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-report/functional/ScenarioWithLegacyAnnotationsCept.php b/test/codeception-report/functional/ScenarioWithLegacyAnnotationsCept.php
new file mode 100644
index 0000000..5c2999e
--- /dev/null
+++ b/test/codeception-report/functional/ScenarioWithLegacyAnnotationsCept.php
@@ -0,0 +1,20 @@
+expect('some condition');
diff --git a/test/codeception-report/unit.suite.yml b/test/codeception-report/unit.suite.yml
new file mode 100644
index 0000000..a81cedd
--- /dev/null
+++ b/test/codeception-report/unit.suite.yml
@@ -0,0 +1,2 @@
+
+actor: UnitTester
diff --git a/test/codeception-report/unit/AnnotationTest.php b/test/codeception-report/unit/AnnotationTest.php
new file mode 100644
index 0000000..081d181
--- /dev/null
+++ b/test/codeception-report/unit/AnnotationTest.php
@@ -0,0 +1,49 @@
+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-report/unit/DataProviderTest.php b/test/codeception-report/unit/DataProviderTest.php
new file mode 100644
index 0000000..670050e
--- /dev/null
+++ b/test/codeception-report/unit/DataProviderTest.php
@@ -0,0 +1,40 @@
+
+ */
+ public static function providerData(): iterable
+ {
+ return [
+ 0 => ['foo', 'foo'],
+ 'a' => ['bar', 'bar'],
+ 'b' => ['foo', 'bar'],
+ ];
+ }
+}
diff --git a/test/codeception-report/unit/StepsTest.php b/test/codeception-report/unit/StepsTest.php
new file mode 100644
index 0000000..bbfefb5
--- /dev/null
+++ b/test/codeception-report/unit/StepsTest.php
@@ -0,0 +1,101 @@
+expectNotToPerformAssertions();
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testNoStepsError(): void
+ {
+ throw new Exception('Error');
+ }
+
+ public function testNoStepsFailure(): void
+ {
+ /** @psalm-suppress UndefinedClass */
+ self::fail('Failure');
+ }
+
+ public function testNoStepsSkipped(): void
+ {
+ /** @psalm-suppress UndefinedClass */
+ self::markTestSkipped('Skipped');
+ }
+
+ public function testSingleSuccessfulStepWithTitle(): void
+ {
+ $this->expectNotToPerformAssertions();
+ $scenario = new Scenario($this);
+ $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();
+
+ $scenario = new Scenario($this);
+ $scenario->runStep(new Comment('Step 1 name'));
+ $scenario->runStep(new Comment('Step 2 name'));
+ }
+
+ public function testTwoStepsFirstFails(): void
+ {
+ $this->expectNotToPerformAssertions();
+
+ $scenario = new Scenario($this);
+ $scenario->runStep($this->createFailingStep('Step 1 name', 'Failure'));
+ $scenario->runStep(new Comment('Step 2 name'));
+ }
+
+ public function testTwoStepsSecondFails(): void
+ {
+ $this->expectNotToPerformAssertions();
+
+ $scenario = new Scenario($this);
+ $scenario->runStep(new Comment('Step 1 name'));
+ $scenario->runStep($this->createFailingStep('Step 2 name', 'Failure'));
+ }
+
+ private function createFailingStep(string $name, string $failure): Step
+ {
+ return new class ($failure, $name) extends Meta {
+ private string $failure;
+
+ public function __construct(string $failure, string $action, array $arguments = [])
+ {
+ parent::__construct($action, $arguments);
+ $this->failure = $failure;
+ }
+
+ public function run(ModuleContainer $container = null): void
+ {
+ $this->setFailed(true);
+ Unit::fail($this->failure);
+ }
+ };
+ }
+}
diff --git a/test/codeception/_support/UnitTester.php b/test/codeception/_support/UnitTester.php
new file mode 100644
index 0000000..a2ff24d
--- /dev/null
+++ b/test/codeception/_support/UnitTester.php
@@ -0,0 +1,29 @@
+>
+ */
+ private static array $testResults = [];
+
+ private ?ProcessorInterface $jsonPathProcessor = null;
+
+ private ?QueryFactoryInterface $jsonPathQueryFactory = null;
+
+ public static function setUpBeforeClass(): void
+ {
+ $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 = $buildPath . DIRECTORY_SEPARATOR . $fileName;
+ if (!is_file($file)) {
+ continue;
+ }
+ $extension = pathinfo($file, PATHINFO_EXTENSION);
+ 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");
+ }
+ self::assertIsString($class);
+ self::assertIsString($method);
+ self::$testResults[$class][$method] = $data;
+ }
+ }
+ }
+
+ /**
+ * @param string $class
+ * @param string $method
+ * @param string $jsonPath
+ * @param non-empty-string $expectedValue
+ * @dataProvider providerSingleNodeValueStartsFromString
+ */
+ 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 static function providerSingleNodeValueStartsFromString(): iterable
+ {
+ 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);
+ }
+
+ /**
+ * @return iterable}>
+ */
+ public static function providerExistingNodeValue(): iterable
+ {
+ return [
+ 'Test case title annotation' => [
+ AnnotationTest::class,
+ 'testTitleAnnotation',
+ '$.name',
+ ['Test title'],
+ ],
+ 'Test case severity annotation' => [
+ AnnotationTest::class,
+ 'testSeverityAnnotation',
+ '$.labels[?(@.name=="severity")].value',
+ ['minor'],
+ ],
+ 'Test case parameter annotation' => [
+ AnnotationTest::class,
+ 'testParameterAnnotation',
+ '$.parameters[?(@.name=="foo")].value',
+ ['bar'],
+ ],
+ 'Test case stories annotation' => [
+ AnnotationTest::class,
+ 'testStoriesAnnotation',
+ '$.labels[?(@.name=="story")].value',
+ ['Story 2', 'Story 1'],
+ ],
+ 'Test case features annotation' => [
+ AnnotationTest::class,
+ 'testFeaturesAnnotation',
+ '$.labels[?(@.name=="feature")].value',
+ ['Feature 2', 'Feature 1'],
+ ],
+ 'Successful test case without steps' => [
+ StepsTest::class,
+ 'testNoStepsSuccess',
+ '$.status',
+ ['passed'],
+ ],
+ 'Successful test case without steps: no steps' => [
+ StepsTest::class,
+ 'testNoStepsSuccess',
+ '$.steps[*]',
+ [],
+ ],
+ 'Error in test case without steps' => [
+ StepsTest::class,
+ 'testNoStepsError',
+ '$.status',
+ ['broken'],
+ ],
+ 'Failure message in test case without steps' => [
+ StepsTest::class,
+ 'testNoStepsFailure',
+ '$.statusDetails.message',
+ ['Failure'],
+ ],
+ 'Test case without steps skipped' => [
+ StepsTest::class,
+ 'testNoStepsSkipped',
+ '$.status',
+ ['skipped'],
+ ],
+ 'Skipped message in test case without steps' => [
+ StepsTest::class,
+ 'testNoStepsSkipped',
+ '$.statusDetails.message',
+ ['Skipped'],
+ ],
+ 'Successful test case with single step: status' => [
+ StepsTest::class,
+ 'testSingleSuccessfulStepWithTitle',
+ '$.status',
+ ['passed'],
+ ],
+ 'Successful test case with single step: step status' => [
+ StepsTest::class,
+ 'testSingleSuccessfulStepWithTitle',
+ '$.steps[*].status',
+ ['passed'],
+ ],
+ 'Successful test case with single step: step name' => [
+ StepsTest::class,
+ 'testSingleSuccessfulStepWithTitle',
+ '$.steps[*].name',
+ ['step 1 name'],
+ ],
+ 'Successful test case with arguments in step: status' => [
+ StepsTest::class,
+ 'testSingleSuccessfulStepWithArguments',
+ '$.status',
+ ['passed'],
+ ],
+ 'Successful test case with arguments in step: step status' => [
+ StepsTest::class,
+ 'testSingleSuccessfulStepWithArguments',
+ '$.steps[*].status',
+ ['passed'],
+ ],
+ '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'],
+ ],
+ 'Nested steps: root names' => [
+ NestedStepsCest::class,
+ 'makeNestedSteps',
+ '$.steps[*].name',
+ ['Step 1'],
+ ],
+ 'Nested steps: level 1 names' => [
+ NestedStepsCest::class,
+ 'makeNestedSteps',
+ '$.steps[?(@.name=="Step 1")].steps[*].name',
+ ['i expect condition 1', 'Step 1.1', 'Step 1.2'],
+ ],
+ 'Nested steps: level 1.1 names' => [
+ NestedStepsCest::class,
+ 'makeNestedSteps',
+ '$.steps..steps[?(@.name=="Step 1.1")].steps[*].name',
+ ['i expect condition 1.1', 'Step 1.1.1'],
+ ],
+ 'Nested steps: level 1.1.1 names' => [
+ NestedStepsCest::class,
+ 'makeNestedSteps',
+ '$.steps..steps[?(@.name=="Step 1.1.1")].steps[*].name',
+ ['i expect condition 1.1.1'],
+ ],
+ 'Nested steps: level 1.2 names' => [
+ NestedStepsCest::class,
+ 'makeNestedSteps',
+ '$.steps..steps[?(@.name=="Step 1.2")].steps[*].name',
+ ['i expect condition 1.2'],
+ ],
+ ];
+ }
+
+ private function getJsonPathProcessor(): ProcessorInterface
+ {
+ return $this->jsonPathProcessor ??= Processor::create();
+ }
+
+ private function getJsonPathQueryFactory(): QueryFactoryInterface
+ {
+ return $this->jsonPathQueryFactory ??= QueryFactory::create();
+ }
+}
diff --git a/test/codeception/unit.suite.yml b/test/codeception/unit.suite.yml
new file mode 100644
index 0000000..bfaf844
--- /dev/null
+++ b/test/codeception/unit.suite.yml
@@ -0,0 +1,6 @@
+
+actor: UnitTester
+coverage:
+ enabled: true
+ include:
+ - src/*
\ No newline at end of file
diff --git a/test/codeception/unit/Internal/ArgumentAsStringTest.php b/test/codeception/unit/Internal/ArgumentAsStringTest.php
new file mode 100644
index 0000000..a77cb87
--- /dev/null
+++ b/test/codeception/unit/Internal/ArgumentAsStringTest.php
@@ -0,0 +1,32 @@
+
+ */
+ public static function providerString(): iterable
+ {
+ return [
+ 'Simple string' => ['a', '"a"'],
+ 'String with tabulation' => ["a\tb", '"a b"'],
+ 'String with line feed' => ["a\nb", '"a\\\\nb"'],
+ 'String with carriage return' => ["a\rb", '"a\\\\rb"'],
+ ];
+ }
+}
diff --git a/test/codeception/unit/Internal/CestProviderTest.php b/test/codeception/unit/Internal/CestProviderTest.php
new file mode 100644
index 0000000..42913c1
--- /dev/null
+++ b/test/codeception/unit/Internal/CestProviderTest.php
@@ -0,0 +1,27 @@
+assertSame(__CLASS__ . '::' . 'a', $cestProvider->getFullName());
+ }
+
+ /**
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function a(): void
+ {
+ }
+}