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 [
[