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