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 +[![Latest Stable Version](http://poser.pugx.org/allure-framework/allure-codeception/v)](https://packagist.org/packages/allure-framework/allure-codeception) +[![Build](https://github.com/allure-framework/allure-codeception/actions/workflows/build.yml/badge.svg)](https://github.com/allure-framework/allure-codeception/actions/workflows/build.yml) +[![Type Coverage](https://shepherd.dev/github/allure-framework/allure-codeception/coverage.svg)](https://shepherd.dev/github/allure-framework/allure-codeception) +[![Psalm Level](https://shepherd.dev/github/allure-framework/allure-codeception/level.svg)](https://shepherd.dev/github/allure-framework/allure-codeception) +[![Total Downloads](http://poser.pugx.org/allure-framework/allure-codeception/downloads)](https://packagist.org/packages/allure-framework/allure-codeception) +[![License](http://poser.pugx.org/allure-framework/allure-codeception/license)](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 + { + } +}