diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 911a7c0..b504aef 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,8 +1,10 @@ name: Build on: - push: ~ - pull_request: ~ + push: + branches: + - master + pull_request: release: types: [created] schedule: @@ -10,76 +12,71 @@ on: cron: "0 1 * * 6" # Run at 1am every Saturday jobs: - tests: + test: + name: PHP ${{ matrix.php-version }} + Symfony ${{ matrix.symfony-version }} runs-on: ubuntu-latest - name: "PHP ${{ matrix.php }}, Symfony ${{ matrix.symfony }}, Behat ${{ matrix.behat }}, ${{ matrix.dependencies }} deps" strategy: fail-fast: false matrix: - php: ["8.1", "8.2", "8.3"] - symfony: ["^5.4", "^6.4", "^7.0"] - behat: ["^3.10"] - dependencies: ["lowest", "highest"] + php-version: + - '8.3' + - '8.4' + - '8.5' + symfony-version: + - '7.4.*' + - '8.0.*' + - '8.1.*' exclude: - - symfony: "^7.0" - php: "8.1" - include: - - php: "8.1" - symfony: "^5.4" - behat: ">=3.0 <3.10" - dependencies: "lowest" - - php: "8.2" - symfony: "^6.4" - behat: ">=3.0 <3.10" - dependencies: "lowest" - - php: "8.3" - symfony: "^7.0" - behat: "4.x-dev" - dependencies: "highest" - - php: "8.4" - symfony: "^8.0" - behat: "4.x-dev" - dependencies: "highest" - + - php-version: '8.3' + symfony-version: '8.0.*' + - php-version: '8.3' + symfony-version: '8.1.*' steps: - - - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + coverage: none + ini-values: "memory_limit=-1, zend.assertions=1" + php-version: ${{ matrix.php-version }} + tools: composer:v2 + + - name: Lock Symfony version + run: composer require --no-update --no-interaction \ + "symfony/filesystem:~${{ matrix.symfony-version }}" \ + "symfony/process:~${{ matrix.symfony-version }}" - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: "${{ matrix.php }}" - coverage: none + - name: Require behat 4.x for Symfony 8+ + if: startsWith(matrix.symfony-version, '8.') + run: | + cat <<< $(jq --indent 4 '.require."behat/behat" = "4.x-dev as 3.31.0"' < composer.json) > composer.json + composer config minimum-stability dev + composer config prefer-stable true - - - name: Restrict Symfony version - if: matrix.symfony != '' - run: | - composer global require --no-progress --no-scripts --no-plugins "symfony/flex" - composer global config --no-plugins allow-plugins.symfony/flex true - composer config extra.symfony.require "${{ matrix.symfony }}" - composer config minimum-stability "dev" - composer config prefer-stable true + - name: Install dependencies + run: composer install --prefer-dist --no-progress - - - name: Restrict Behat version - run: composer require --no-update "behat/behat:${{ matrix.behat }}" + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress - - - name: Install dependencies - run: composer update ${{ matrix.dependencies == 'lowest' && '--prefer-lowest --prefer-stable' || '' }} + - name: Run tests + run: vendor/bin/behat -f progress --strict -vvv --no-interaction --colors - - - name: Composer validate - if: matrix.behat != '4.x-dev' - run: composer validate --strict --ansi + validate-composer: + name: Validate composer.json + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 - - - name: Run PHPStan - if: matrix.behat != '>=3.0 <3.10' - run: vendor/bin/phpstan analyse --no-progress + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + coverage: none + php-version: '8.3' + tools: composer:v2 - - - name: Run tests - run: vendor/bin/behat ${{ matrix.behat != '4.x-dev' && '--config behat.yml.dist' || '' }} -f progress --strict -vvv --no-interaction --colors + - name: Validate composer.json + run: composer validate --strict diff --git a/behat.dist.php b/behat.dist.php index 40f1197..79f4425 100644 --- a/behat.dist.php +++ b/behat.dist.php @@ -12,7 +12,7 @@ (new Profile('default')) ->withSuite( (new Suite('default')) - ->withPaths(__DIR__ . '/features/attributes') + ->withPaths(__DIR__ . '/features') ->withContexts(TestContext::class) ) ); diff --git a/behat.yml.dist b/behat.yml.dist deleted file mode 100644 index 7566c49..0000000 --- a/behat.yml.dist +++ /dev/null @@ -1,7 +0,0 @@ -default: - suites: - default: - paths: - - '%paths.base%/features/annotations' - contexts: - - FriendsOfBehat\TestContext\Context\TestContext diff --git a/composer.json b/composer.json index 6f73d21..f55869c 100644 --- a/composer.json +++ b/composer.json @@ -10,17 +10,17 @@ } ], "require": { - "php": "^8.1", - "behat/behat": "^3.0 || ^4.0", - "symfony/filesystem": "^5.4 || ^6.4 || ^7.0 || ^8.0", - "symfony/process": "^5.4 || ^6.4 || ^7.0 || ^8.0" + "php": "^8.3", + "behat/behat": "^3.31", + "symfony/filesystem": "^7.4", + "symfony/process": "^7.4" }, "require-dev": { "phpstan/phpstan": "^2.1" }, "autoload": { - "files": [ - "src/autoload.php" - ] + "psr-4": { + "FriendsOfBehat\\TestContext\\": "src/" + } } } diff --git a/features/annotations/basic_steps_for_testing.feature b/features/annotations/basic_steps_for_testing.feature deleted file mode 100644 index 564061f..0000000 --- a/features/annotations/basic_steps_for_testing.feature +++ /dev/null @@ -1,108 +0,0 @@ -Feature: Basic steps for testing - In order to test a context used to test Behat - As a Behat extension developer - I want to run Behat while running Behat - - Background: - Given a context file "features/bootstrap/FeatureContext.php" containing: - """ - ['extensions' => ['Unknown\Extension' => null]], + ]); """ When I run Behat Then it should fail with "Behat\Testwork\ServiceContainer\Exception\ExtensionInitializationException" diff --git a/features/attributes/independent_scenarios.feature b/features/independent_scenarios.feature similarity index 100% rename from features/attributes/independent_scenarios.feature rename to features/independent_scenarios.feature diff --git a/features/annotations/simple_scenarios.feature b/features/simple_scenarios.feature similarity index 100% rename from features/annotations/simple_scenarios.feature rename to features/simple_scenarios.feature diff --git a/phpstan-bootstrap.php b/phpstan-bootstrap.php new file mode 100644 index 0000000..0cce751 --- /dev/null +++ b/phpstan-bootstrap.php @@ -0,0 +1,6 @@ + - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FriendsOfBehat\TestContext\Context; - -use Behat\Behat\Context\Context; -use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\Process\PhpExecutableFinder; -use Symfony\Component\Process\Process; - -final class TestContext implements Context -{ - private static string $workingDir; - - private static Filesystem $filesystem; - - private static string $phpBin; - - private ?Process $process = null; - - /** - * @BeforeFeature - */ - public static function beforeFeature(): void - { - self::$workingDir = sprintf('%s/%s/', sys_get_temp_dir(), uniqid('', true)); - self::$filesystem = new Filesystem(); - self::$phpBin = self::findPhpBinary(); - } - - /** - * @BeforeScenario - */ - public function beforeScenario(): void - { - self::$filesystem->remove(self::$workingDir); - self::$filesystem->mkdir(self::$workingDir, 0777); - } - - /** - * @AfterScenario - */ - public function afterScenario(): void - { - self::$filesystem->remove(self::$workingDir); - } - - /** - * @Given /^a Behat configuration containing(?: "([^"]+)"|:)$/ - */ - public function thereIsConfiguration(?string $content): void - { - $this->thereIsFile('behat.yml', $content); - } - - /** - * @Given /^a (?:.+ |)file "([^"]+)" containing(?: "([^"]+)"|:)$/ - */ - public function thereIsFile(?string $file, ?string $content): void - { - self::$filesystem->dumpFile(self::$workingDir . '/' . $file, (string) $content); - } - - /** - * @Given /^a feature file containing(?: "([^"]+)"|:)$/ - */ - public function thereIsFeatureFile(?string $content): void - { - $this->thereIsFile(sprintf('features/%s.feature', md5(uniqid('', true))), $content); - } - - /** - * @Given /^a feature file with passing scenario$/ - */ - public function thereIsFeatureFileWithPassingScenario(): void - { - $this->thereIsFile('features/bootstrap/FeatureContext.php', <<thereIsFeatureFile(<<thereIsFile('features/bootstrap/FeatureContext.php', <<thereIsFeatureFile(<<thereIsFile('features/bootstrap/FeatureContext.php', <<thereIsFeatureFile(<<thereIsFile('features/bootstrap/FeatureContext.php', <<thereIsFeatureFile(<<process = new Process([self::$phpBin, trim(escapeshellarg(BEHAT_BIN_PATH), "'"), '--strict', '-vvv', '--no-interaction', '--lang=en'], self::$workingDir); - $this->process->start(); - $this->process->wait(); - } - - /** - * @Then /^it should pass$/ - */ - public function itShouldPass(): void - { - if (0 === $this->getProcessExitCode()) { - return; - } - - throw new \DomainException( - 'Behat was expecting to pass, but failed with the following output:' . PHP_EOL . PHP_EOL . $this->getProcessOutput() - ); - } - - /** - * @Then /^it should pass with(?: "([^"]+)"|:)$/ - */ - public function itShouldPassWith(?string $expectedOutput): void - { - $this->itShouldPass(); - $this->assertOutputMatches($expectedOutput); - } - - /** - * @Then /^it should fail$/ - */ - public function itShouldFail(): void - { - if (0 !== $this->getProcessExitCode()) { - return; - } - - throw new \DomainException( - 'Behat was expecting to fail, but passed with the following output:' . PHP_EOL . PHP_EOL . $this->getProcessOutput() - ); - } - - /** - * @Then /^it should fail with(?: "([^"]+)"|:)$/ - */ - public function itShouldFailWith(?string $expectedOutput): void - { - $this->itShouldFail(); - $this->assertOutputMatches($expectedOutput); - } - - /** - * @Then /^it should end with(?: "([^"]+)"|:)$/ - */ - public function itShouldEndWith(?string $expectedOutput): void - { - $this->assertOutputMatches($expectedOutput); - } - - private function assertOutputMatches(?string $expectedOutput): void - { - $pattern = '/' . preg_quote((string) $expectedOutput, '/') . '/sm'; - $output = $this->getProcessOutput(); - - $result = preg_match($pattern, $output); - if (false === $result) { - throw new \InvalidArgumentException('Invalid pattern given:' . $pattern); - } - - if (0 === $result) { - throw new \DomainException(sprintf( - 'Pattern "%s" does not match the following output:' . PHP_EOL . PHP_EOL . '%s', - $pattern, - $output - )); - } - } - - private function getProcessOutput(): string - { - $this->assertProcessIsAvailable(); - - return sprintf('%s%s', $this->process?->getErrorOutput(), $this->process?->getOutput()); - } - - private function getProcessExitCode(): int - { - $this->assertProcessIsAvailable(); - - return $this->process?->getExitCode() ?? -1; - } - - private function assertProcessIsAvailable(): void - { - if (null === $this->process) { - throw new \BadMethodCallException('Behat process cannot be found. Did you run it before making assertions?'); - } - } - - private static function findPhpBinary(): string - { - $phpBinary = (new PhpExecutableFinder())->find(); - if (false === $phpBinary) { - throw new \RuntimeException('Unable to find the PHP executable.'); - } - - return $phpBinary; - } -} diff --git a/src/Behat310/Context/TestContext.php b/src/Behat310/Context/TestContext.php deleted file mode 100644 index 8a635c7..0000000 --- a/src/Behat310/Context/TestContext.php +++ /dev/null @@ -1,332 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace FriendsOfBehat\TestContext\Context; - -use Behat\Behat\Context\Context; -use Behat\Hook\AfterScenario; -use Behat\Hook\BeforeFeature; -use Behat\Hook\BeforeScenario; -use Behat\Step\Given; -use Behat\Step\Then; -use Behat\Step\When; -use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\Process\PhpExecutableFinder; -use Symfony\Component\Process\Process; - -final class TestContext implements Context -{ - private static string $workingDir; - - private static Filesystem $filesystem; - - private static string $phpBin; - - private ?Process $process = null; - - #[BeforeFeature] - public static function beforeFeature(): void - { - self::$workingDir = sprintf('%s/%s/', sys_get_temp_dir(), uniqid('', true)); - self::$filesystem = new Filesystem(); - self::$phpBin = self::findPhpBinary(); - } - - #[BeforeScenario] - public function beforeScenario(): void - { - self::$filesystem->remove(self::$workingDir); - self::$filesystem->mkdir(self::$workingDir, 0777); - } - - #[AfterScenario] - public function afterScenario(): void - { - self::$filesystem->remove(self::$workingDir); - } - - #[Given('/^a Behat configuration containing(?: "([^"]+)"|:)$/')] - public function thereIsConfiguration(?string $content): void - { - if (self::isBehat4()) { - $this->thereIsFile('behat.php', sprintf( - <<<'PHP' - $extensionConfig) { - $resolved[$this->resolveExtensionClassName($name)] = $extensionConfig; - } - $profile['extensions'] = $resolved; - } - - return $config; - } - - private function resolveExtensionClassName(string $name): string - { - if (class_exists($name)) { - return $name; - } - - $parts = explode('\\', $name); - $last = preg_replace('/Extension$/', '', end($parts)) . 'Extension'; - $guessed = $name . '\\ServiceContainer\\' . $last; - - if (class_exists($guessed)) { - return $guessed; - } - - return $name; - } -}; -PHP, - var_export((string) $content, true), - )); - - return; - } - - $this->thereIsFile('behat.yml', $content); - } - - #[Given('/^a (?:.+ |)file "([^"]+)" containing(?: "([^"]+)"|:)$/')] - public function thereIsFile(?string $file, ?string $content): void - { - self::$filesystem->dumpFile(self::$workingDir . '/' . $file, (string) $content); - } - - #[Given('/^a feature file containing(?: "([^"]+)"|:)$/')] - public function thereIsFeatureFile(?string $content): void - { - $this->thereIsFile(sprintf('features/%s.feature', md5(uniqid('', true))), $content); - } - - #[Given('/^a feature file with passing scenario$/')] - public function thereIsFeatureFileWithPassingScenario(): void - { - $this->thereIsFile('features/bootstrap/FeatureContext.php', <<thereIsFeatureFile(<<thereIsFile('features/bootstrap/FeatureContext.php', <<thereIsFeatureFile(<<thereIsFile('features/bootstrap/FeatureContext.php', <<thereIsFeatureFile(<<thereIsFile('features/bootstrap/FeatureContext.php', <<thereIsFeatureFile(<<process = new Process([self::$phpBin, trim(escapeshellarg(BEHAT_BIN_PATH), "'"), '--strict', '-vvv', '--no-interaction', '--lang=en'], self::$workingDir); - $this->process->start(); - $this->process->wait(); - } - - #[Then('/^it should pass$/')] - public function itShouldPass(): void - { - if (0 === $this->getProcessExitCode()) { - return; - } - - throw new \DomainException( - 'Behat was expecting to pass, but failed with the following output:' . PHP_EOL . PHP_EOL . $this->getProcessOutput() - ); - } - - #[Then('/^it should pass with(?: "([^"]+)"|:)$/')] - public function itShouldPassWith(?string $expectedOutput): void - { - $this->itShouldPass(); - $this->assertOutputMatches($expectedOutput); - } - - #[Then('/^it should fail$/')] - public function itShouldFail(): void - { - if (0 !== $this->getProcessExitCode()) { - return; - } - - throw new \DomainException( - 'Behat was expecting to fail, but passed with the following output:' . PHP_EOL . PHP_EOL . $this->getProcessOutput() - ); - } - - #[Then('/^it should fail with(?: "([^"]+)"|:)$/')] - public function itShouldFailWith(?string $expectedOutput): void - { - $this->itShouldFail(); - $this->assertOutputMatches($expectedOutput); - } - - #[Then('/^it should end with(?: "([^"]+)"|:)$/')] - public function itShouldEndWith(?string $expectedOutput): void - { - $this->assertOutputMatches($expectedOutput); - } - - private function assertOutputMatches(?string $expectedOutput): void - { - $pattern = '/' . preg_quote((string) $expectedOutput, '/') . '/sm'; - $output = $this->getProcessOutput(); - - $result = preg_match($pattern, $output); - if (false === $result) { - throw new \InvalidArgumentException('Invalid pattern given:' . $pattern); - } - - if (0 === $result) { - throw new \DomainException(sprintf( - 'Pattern "%s" does not match the following output:' . PHP_EOL . PHP_EOL . '%s', - $pattern, - $output - )); - } - } - - private function getProcessOutput(): string - { - $this->assertProcessIsAvailable(); - - return sprintf('%s%s', $this->process?->getErrorOutput(), $this->process?->getOutput()); - } - - private function getProcessExitCode(): int - { - $this->assertProcessIsAvailable(); - - return $this->process?->getExitCode() ?? -1; - } - - private function assertProcessIsAvailable(): void - { - if (null === $this->process) { - throw new \BadMethodCallException('Behat process cannot be found. Did you run it before making assertions?'); - } - } - - private static function findPhpBinary(): string - { - $phpBinary = (new PhpExecutableFinder())->find(); - if (false === $phpBinary) { - throw new \RuntimeException('Unable to find the PHP executable.'); - } - - return $phpBinary; - } - - private static function isBehat4(): bool - { - return class_exists(\Behat\Config\Config::class); - } -} diff --git a/src/Context/TestContext.php b/src/Context/TestContext.php new file mode 100644 index 0000000..a60e515 --- /dev/null +++ b/src/Context/TestContext.php @@ -0,0 +1,235 @@ +filesystem = new Filesystem(); + $this->workingDir = sprintf('%s/%s', sys_get_temp_dir(), uniqid('', true)); + $this->filesystem->mkdir($this->workingDir, 0777); + $this->process = null; + } + + #[AfterScenario] + public function afterScenario(): void + { + $this->filesystem->remove($this->workingDir); + } + + #[Given('/^a Behat configuration containing(?: "([^"]+)"|:)$/')] + public function thereIsConfiguration(string $content): void + { + $this->filesystem->dumpFile($this->workingDir . '/behat.dist.php', $content); + } + + #[Given('/^a (?:.+ |)file "([^"]+)" containing(?: "([^"]+)"|:)$/')] + public function thereIsFile(string $file, string $content): void + { + if (str_ends_with($file, '.php') && str_contains($content, '* @')) { + $content = $this->replaceAnnotationsWithAttributes($content); + } + + $this->filesystem->dumpFile($this->workingDir . '/' . $file, $content); + } + + #[Given('/^a feature file containing(?: "([^"]+)"|:)$/')] + public function thereIsFeatureFile(string $content): void + { + $this->thereIsFile(sprintf('features/%s.feature', uniqid('', true)), $content); + } + + #[Given('/^a feature file with passing scenario$/')] + public function thereIsFeatureFileWithPassingScenario(): void + { + $this->writeSharedFeatureContext(); + $this->thereIsFeatureFile(<<<'FEA' +Feature: Passing feature + + Scenario: Passing scenario + Then it passes +FEA); + } + + #[Given('/^a feature file with failing scenario$/')] + public function thereIsFeatureFileWithFailingScenario(): void + { + $this->writeSharedFeatureContext(); + $this->thereIsFeatureFile(<<<'FEA' +Feature: Failing feature + + Scenario: Failing scenario + Then it fails +FEA); + } + + #[Given('/^a feature file with scenario with missing step$/')] + public function thereIsFeatureFileWithScenarioWithMissingStep(): void + { + $this->writeSharedFeatureContext(); + $this->thereIsFeatureFile(<<<'FEA' +Feature: Feature with missing step + + Scenario: Scenario with missing step + Then it does not have this step +FEA); + } + + #[Given('/^a feature file with scenario with pending step$/')] + public function thereIsFeatureFileWithScenarioWithPendingStep(): void + { + $this->writeSharedFeatureContext(); + $this->thereIsFeatureFile(<<<'FEA' +Feature: Feature with pending step + + Scenario: Scenario with pending step + Then it has this step as pending +FEA); + } + + #[When('/^I run Behat$/')] + public function iRunBehat(): void + { + $this->process = new Process( + [PHP_BINARY, BEHAT_BIN_PATH, '--strict', '-vvv', '--no-interaction', '--lang=en'], + $this->workingDir, + ); + $this->process->run(); + } + + #[Then('/^it should pass$/')] + public function itShouldPass(): void + { + if (0 === $this->getProcessExitCode()) { + return; + } + + throw new \DomainException( + 'Behat was expecting to pass, but failed with the following output:' . PHP_EOL . PHP_EOL . $this->getProcessOutput() + ); + } + + #[Then('/^it should pass with(?: "([^"]+)"|:)$/')] + public function itShouldPassWith(string $expectedOutput): void + { + $this->itShouldPass(); + $this->assertOutputMatches($expectedOutput); + } + + #[Then('/^it should fail$/')] + public function itShouldFail(): void + { + if (0 !== $this->getProcessExitCode()) { + return; + } + + throw new \DomainException( + 'Behat was expecting to fail, but passed with the following output:' . PHP_EOL . PHP_EOL . $this->getProcessOutput() + ); + } + + #[Then('/^it should fail with(?: "([^"]+)"|:)$/')] + public function itShouldFailWith(string $expectedOutput): void + { + $this->itShouldFail(); + $this->assertOutputMatches($expectedOutput); + } + + #[Then('/^it should end with(?: "([^"]+)"|:)$/')] + public function itShouldEndWith(string $expectedOutput): void + { + $this->assertOutputMatches($expectedOutput); + } + + private function writeSharedFeatureContext(): void + { + $this->thereIsFile('features/bootstrap/FeatureContext.php', <<<'PHP' +getProcessOutput(); + + if (!preg_match('/' . preg_quote($expectedOutput, '/') . '/sm', $output)) { + throw new \DomainException(sprintf( + 'Expected output to contain "%s", got:' . PHP_EOL . PHP_EOL . '%s', + $expectedOutput, + $output, + )); + } + } + + private function getProcessOutput(): string + { + $process = $this->getProcess(); + + return $process->getErrorOutput() . $process->getOutput(); + } + + private function getProcessExitCode(): int + { + return $this->getProcess()->getExitCode() ?? 1; + } + + private function getProcess(): Process + { + if (null === $this->process) { + throw new \BadMethodCallException('Behat process cannot be found. Did you run it before making assertions?'); + } + + return $this->process; + } + + private function replaceAnnotationsWithAttributes(string $code): string + { + return (string) preg_replace_callback( + '/^( *)\/\*\*\s*@(Given|When|Then|BeforeScenario|AfterScenario|BeforeFeature|AfterFeature)(?:\s+(.+?))?\s*\*\/$/m', + static function (array $m): string { + $indent = $m[1]; + $name = $m[2]; + $arg = isset($m[3]) ? "('" . str_replace("'", "\\'", $m[3]) . "')" : ''; + $ns = in_array($name, ['Given', 'When', 'Then'], true) ? 'Step' : 'Hook'; + + return "{$indent}#[\\Behat\\{$ns}\\{$name}{$arg}]"; + }, + $code, + ); + } +} diff --git a/src/autoload.php b/src/autoload.php deleted file mode 100644 index c34cc5d..0000000 --- a/src/autoload.php +++ /dev/null @@ -1,18 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -if (class_exists(\Behat\Hook\BeforeFeature::class)) { - require_once __DIR__ . '/Behat310/Context/TestContext.php'; -} else { - require_once __DIR__ . '/Behat3/Context/TestContext.php'; -}