diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e6eb308..0020d45 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,6 +25,10 @@ jobs: - php: '8.1' symfony-versions: '^5.4' coverage: 'none' + - description: 'Symfony 8 + Behat 4.x-dev' + php: '8.4' + symfony-versions: '8.0.*' + coverage: 'none' - description: 'Log Code Coverage' php: '8.4' coverage: 'xdebug' @@ -60,6 +64,10 @@ jobs: key: ${{ runner.os }}-${{ matrix.php }}-${{ matrix.symfony-versions }}-composer-${{ hashFiles('composer.json') }} restore-keys: ${{ runner.os }}-${{ matrix.php }}-${{ matrix.symfony-versions }}-composer + - name: Use Behat 4.x-dev with Symfony 8 + if: matrix.symfony-versions == '8.0.*' + run: composer require behat/behat:4.x-dev --no-update --no-scripts + - name: Update Symfony version if: matrix.symfony-versions != '' run: | @@ -86,4 +94,22 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} file: './coverage.xml' - fail_ci_if_error: true + fail_ci_if_error: false + + prefer-lowest: + name: PHPUnit (composer --prefer-lowest) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + + - name: Install lowest compatible dependencies + run: composer update --prefer-lowest --no-interaction --no-progress + + - name: Run PHPUnit tests + run: composer phpunit diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dd45570 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# Agents + +## Cursor Cloud specific instructions + +This is a PHP Composer package (Symfony Bundle) — not a standalone application. No databases, caches, or external services required. + +### Quick reference + +All dev commands are Composer scripts defined in `composer.json`: + +| Command | Purpose | +|---|---| +| `composer run dev-checks` | Full suite: validate + phpstan + phpcs + phpunit | +| `composer run phpunit` | PHPUnit tests only (no coverage) | +| `composer run phpstan` | Static analysis (max level) | +| `composer run code-style` | PHP CodeSniffer lint | +| `composer run code-style-fix` | Auto-fix coding standard violations | + +See `CONTRIBUTING.md` for full contribution guidelines. + +### Gotchas + +- The `phpcs.xml.dist` uses `ignore_errors_on_exit=1`, so `composer run code-style` returns exit code 0 even with errors. Check output text for `ERROR` lines. +- PHPUnit config (`phpunit.xml.dist`) uses some deprecated attributes for PHPUnit 10 — the deprecation warnings are expected and harmless. +- The repo's `docker-compose.yaml` targets PHP 8.0 (outdated vs the 8.1+ requirement). Use a local PHP 8.1+ install instead of the Docker setup. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..25a5e80 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: cs-fix rector phpunit phpstan phpcs dev-checks + +cs-fix: + composer run code-style-fix + +rector: + @if [ -x vendor/bin/rector ]; then vendor/bin/rector --dry-run; else echo "rector not installed; skipping"; fi + +phpunit: + composer run phpunit + +phpstan: + composer run phpstan + +phpcs: + composer run code-style + +dev-checks: + composer run dev-checks diff --git a/composer.json b/composer.json index 65da238..28bd8df 100644 --- a/composer.json +++ b/composer.json @@ -25,23 +25,26 @@ } ], "license": "MIT", + "minimum-stability": "stable", + "prefer-stable": true, "require": { "ext-json": "*", "php": "^8.1", - "behat/behat": "^3.0", - "symfony/config": "^5.4 || ^6.4 || ^7.0", - "symfony/dependency-injection": "^5.4.34 || ^6.4 || ^7.1", - "symfony/http-client": "^5.4 || ^6.4 || ^7.1", - "symfony/http-kernel": "^5.4 || ^6.4 || ^7.1", - "symfony/routing": "^5.4 || ^6.4 || ^7.1", + "behat/behat": "^3.0 || 4.x-dev@dev", + "symfony/config": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^5.4.34 || ^6.4 || ^7.1 || ^8.0", + "symfony/http-client": "^5.4 || ^6.4 || ^7.1 || ^8.0", + "symfony/http-kernel": "^5.4 || ^6.4 || ^7.1 || ^8.0", + "symfony/routing": "^5.4 || ^6.4 || ^7.1 || ^8.0", "macpaw/similar-arrays": "^1.0" }, "require-dev": { "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^10.0", - "slevomat/coding-standard": "^7.0", - "squizlabs/php_codesniffer": "^3.6", - "doctrine/orm": "^2.0" + "phpunit/phpunit": "^10.5 || ^11.0 || ^12.0", + "slevomat/coding-standard": "^8.0", + "squizlabs/php_codesniffer": "^4.0", + "doctrine/orm": "^2.0 || ^3.0", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" }, "autoload": { "psr-4": { diff --git a/phpcs.xml.dist b/phpcs.xml.dist index ad8abea..f6bb758 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -14,7 +14,19 @@ - + + + + + + + + + + + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index c3428b2..ee0efd7 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -3,14 +3,5 @@ parameters: - src level: max checkExplicitMixed: false - ignoreErrors: - - - message: '#Parameter \#1 \$json of function json_decode expects string, string\|false given.*#' - count: 3 - path: ./src/Context/ApiContext.php - - - message: '#Parameter \#3 \$actualJSON of method BehatApiContext\\Context\\ApiContext::compareStructureResponse\(\) expects string, string\|false given.*#' - count: 1 - path: ./src/Context/ApiContext.php - - - identifier: missingType.iterableValue + # Aligns Symfony Config generics with runtime checks (e.g. ArrayNodeDefinition) across Symfony versions. + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist index afc133c..185d7a5 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,23 +1,22 @@ + stopOnFailure="false" + failOnPhpunitWarning="false"> tests - + ./src + + diff --git a/src/Context/ApiContext.php b/src/Context/ApiContext.php index 4ee2bff..4998c91 100644 --- a/src/Context/ApiContext.php +++ b/src/Context/ApiContext.php @@ -246,10 +246,11 @@ public function responseStatusCodeShouldBe(string $httpStatus): void public function responseIsJson(): void { $response = $this->getResponse(); - $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + $body = $this->getResponseBody($response); + $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); if (empty($data)) { - throw new RuntimeException("Response was not JSON\n" . $response->getContent()); + throw new RuntimeException("Response was not JSON\n" . $body); } } @@ -258,7 +259,7 @@ public function responseIsJson(): void */ public function responseEmpty(): void { - if (!empty($this->getResponse()->getContent())) { + if ($this->getResponseBody($this->getResponse()) !== '') { throw new RuntimeException('Content not empty'); } } @@ -271,7 +272,7 @@ public function responseEmpty(): void public function responseShouldBeJson(PyStringNode $string): void { $expectedResponse = json_decode(trim($string->getRaw()), true, 512, JSON_THROW_ON_ERROR); - $actualResponse = json_decode($this->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + $actualResponse = json_decode($this->getResponseBody($this->getResponse()), true, 512, JSON_THROW_ON_ERROR); if ($expectedResponse !== $actualResponse) { $prettyJSON = json_encode($actualResponse, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT, 512); @@ -286,7 +287,7 @@ public function responseShouldBeJson(PyStringNode $string): void */ public function iGetParamFromJsonResponse(string $paramPath, string $valueKey): void { - $actualResponse = json_decode($this->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + $actualResponse = json_decode($this->getResponseBody($this->getResponse()), true, 512, JSON_THROW_ON_ERROR); $pathKeys = explode('.', $paramPath); foreach ($pathKeys as $key) { @@ -307,7 +308,7 @@ public function iGetParamFromJsonResponse(string $paramPath, string $valueKey): */ public function responseShouldBeJsonWithVariableFields(string $variableFields, PyStringNode $string): void { - $this->compareStructureResponse($variableFields, $string, $this->getResponse()->getContent()); + $this->compareStructureResponse($variableFields, $string, $this->getResponseBody($this->getResponse())); } protected function compareStructureResponse( @@ -388,6 +389,11 @@ protected function checkResponseHeader(string $headerName, string $headerValue): } } + /** + * @param array $requestParams + * + * @return array + */ protected function convertRunnableCodeParams(array $requestParams): array { foreach ($requestParams as $key => $value) { @@ -449,8 +455,21 @@ protected function getResponse(): Response return $this->response; } + /** + * @return array + */ public function geRequestParams(): array { return $this->requestParams; } + + private function getResponseBody(Response $response): string + { + $content = $response->getContent(); + if ($content === false) { + throw new RuntimeException('The response body is not available.'); + } + + return $content; + } } diff --git a/src/DependencyInjection/BehatApiContextExtension.php b/src/DependencyInjection/BehatApiContextExtension.php index 3571305..22d1437 100644 --- a/src/DependencyInjection/BehatApiContextExtension.php +++ b/src/DependencyInjection/BehatApiContextExtension.php @@ -8,17 +8,20 @@ use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; -use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; class BehatApiContextExtension extends Extension { + /** + * @param array $configs + */ public function load(array $configs, ContainerBuilder $container): void { $configuration = new Configuration(); /** @var array $config */ $config = $this->processConfiguration($configuration, $configs); - $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); + $loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); $this->loadApiContext($config, $loader, $container); } @@ -28,10 +31,10 @@ public function load(array $configs, ContainerBuilder $container): void */ private function loadApiContext( array $config, - XmlFileLoader $loader, + PhpFileLoader $loader, ContainerBuilder $container ): void { - $this->safeLoad($loader, 'api_context.xml'); + $this->safeLoad($loader, 'api_context.php'); $this->configureKernelResetManagers( $config, @@ -62,7 +65,7 @@ private function configureKernelResetManagers( } } - private function safeLoad(XmlFileLoader $loader, string $file): void + private function safeLoad(PhpFileLoader $loader, string $file): void { $loader->load($file); } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 0429c95..258fb04 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -4,6 +4,8 @@ namespace BehatApiContext\DependencyInjection; +use LogicException; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\NodeBuilder; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; @@ -13,7 +15,15 @@ class Configuration implements ConfigurationInterface public function getConfigTreeBuilder(): TreeBuilder { $treeBuilder = new TreeBuilder('behat_api_context'); - $root = $treeBuilder->getRootNode()->children(); + $rootNode = $treeBuilder->getRootNode(); + // Symfony TreeBuilder root is always ArrayNodeDefinition; kept for static analysis / defensive parity. + // @codeCoverageIgnoreStart + if (!$rootNode instanceof ArrayNodeDefinition) { + throw new LogicException('Expected configuration root to be an array node.'); + } + // @codeCoverageIgnoreEnd + + $root = $rootNode->children(); $this->addKernelResetManagersSection($root); @@ -22,10 +32,9 @@ public function getConfigTreeBuilder(): TreeBuilder private function addKernelResetManagersSection(NodeBuilder $builder): void { - $builder - ->arrayNode('kernel_reset_managers') - ->scalarPrototype()->end() - ->end() - ->end(); + $kernelResetManagers = $builder->arrayNode('kernel_reset_managers'); + $kernelResetManagers->scalarPrototype()->end(); + $kernelResetManagers->end(); + $builder->end(); } } diff --git a/src/Resources/config/api_context.php b/src/Resources/config/api_context.php new file mode 100644 index 0000000..45df43f --- /dev/null +++ b/src/Resources/config/api_context.php @@ -0,0 +1,20 @@ +services(); + + $services->set(ApiContext::class) + ->public() + ->autowire() + ->autoconfigure(); + + $services->set(DoctrineResetManager::class) + ->autowire(false) + ->autoconfigure(false); +}; diff --git a/src/Resources/config/api_context.xml b/src/Resources/config/api_context.xml deleted file mode 100644 index 0a6ade0..0000000 --- a/src/Resources/config/api_context.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - diff --git a/tests/Unit/Context/Api/ApiContextResponseBodyTest.php b/tests/Unit/Context/Api/ApiContextResponseBodyTest.php new file mode 100644 index 0000000..cc780cd --- /dev/null +++ b/tests/Unit/Context/Api/ApiContextResponseBodyTest.php @@ -0,0 +1,26 @@ +createMock(Response::class); + $response->method('getContent')->willReturn(false); + + $method = new ReflectionMethod(ApiContext::class, 'getResponseBody'); + $method->setAccessible(true); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The response body is not available.'); + $method->invoke($this->apiContext, $response); + } +} diff --git a/tests/Unit/Context/Api/ApiContextTest.php b/tests/Unit/Context/Api/ApiContextTest.php index b577e87..157ee57 100644 --- a/tests/Unit/Context/Api/ApiContextTest.php +++ b/tests/Unit/Context/Api/ApiContextTest.php @@ -5,6 +5,7 @@ namespace BehatApiContext\Tests\Unit\Context\Api; use Behat\Gherkin\Node\PyStringNode; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; class ApiContextTest extends ApiContextTestCase @@ -12,12 +13,7 @@ class ApiContextTest extends ApiContextTestCase private const PARAMS_VALUES = 'paramsValues'; private const INITIAL_PARAM_VALUE = 'initialParamValue'; - /** - * @param PyStringNode $paramsValues - * @param string $initialParamValue - * - * @dataProvider getTheRequestContainsParamsSuccess - */ + #[DataProvider('getTheRequestContainsParamsSuccess')] public function testTheRequestContainsParamsSuccess(PyStringNode $paramsValues, string $initialParamValue): void { $this->assertTrue(str_contains($paramsValues->getStrings()[3], $initialParamValue)); @@ -69,18 +65,14 @@ public function testTheRequestContainsParamsSuccess(PyStringNode $paramsValues, ); } - /** - * @param PyStringNode $paramsValues - * - * @dataProvider getTheRequestContainsParamsRuntimeException - */ + #[DataProvider('getTheRequestContainsParamsRuntimeException')] public function testTheRequestContainsParamsRuntimeException(PyStringNode $paramsValues): void { $this->expectException(RuntimeException::class); $this->apiContext->theRequestContainsParams($paramsValues); } - public function getTheRequestContainsParamsSuccess(): array + public static function getTheRequestContainsParamsSuccess(): array { return [ [ @@ -107,7 +99,7 @@ public function getTheRequestContainsParamsSuccess(): array ]; } - public function getTheRequestContainsParamsRuntimeException(): array + public static function getTheRequestContainsParamsRuntimeException(): array { return [ [ diff --git a/tests/Unit/Context/Api/WhenApiContextsTest.php b/tests/Unit/Context/Api/WhenApiContextsTest.php index ab96910..4fca749 100644 --- a/tests/Unit/Context/Api/WhenApiContextsTest.php +++ b/tests/Unit/Context/Api/WhenApiContextsTest.php @@ -358,12 +358,10 @@ protected function getKernelMock(): KernelInterface&TerminableInterface $kernel ->expects($this->any()) ->method('terminate') - ->will( - $this->returnCallback(function (Request $request, Response $response): void { - $this->request = $request; - $this->response = $response; - }), - ); + ->willReturnCallback(function (Request $request, Response $response): void { + $this->request = $request; + $this->response = $response; + }); } assert($kernel instanceof KernelInterface); diff --git a/tests/Unit/Service/ArrayManagerTest.php b/tests/Unit/Service/ArrayManagerTest.php index 25b1e92..7008ed2 100644 --- a/tests/Unit/Service/ArrayManagerTest.php +++ b/tests/Unit/Service/ArrayManagerTest.php @@ -6,6 +6,7 @@ use BehatApiContext\Service\StringManager; use RuntimeException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class ArrayManagerTest extends TestCase @@ -21,13 +22,7 @@ protected function setUp(): void $this->stringManager = new StringManager(); } - /** - * @param array $substitutionValues - * @param string $initialString - * @param string $resultString - * - * @dataProvider getSubstituteValuesDataProvider - */ + #[DataProvider('getSubstituteValuesDataProvider')] public function testSubstituteValuesSuccess( array $substitutionValues, string $initialString, @@ -37,7 +32,7 @@ public function testSubstituteValuesSuccess( self::assertSame($resultString, $result); } - public function getSubstituteValuesDataProvider(): array + public static function getSubstituteValuesDataProvider(): array { return [ [