diff --git a/.github/.release-please-config.json b/.github/.release-please-config.json index bcaf970c..e13567b1 100644 --- a/.github/.release-please-config.json +++ b/.github/.release-please-config.json @@ -106,6 +106,12 @@ "component": "bridge-rector", "include-component-in-tag": true, "changelog-path": "CHANGELOG.md" + }, + "bridge/vcr": { + "package-name": "testo/bridge-vcr", + "component": "bridge-vcr", + "include-component-in-tag": true, + "changelog-path": "CHANGELOG.md" } }, "bump-patch-for-minor-pre-major": true, diff --git a/.github/workflows/split-publish.yml b/.github/workflows/split-publish.yml index 658df876..36d7cbec 100644 --- a/.github/workflows/split-publish.yml +++ b/.github/workflows/split-publish.yml @@ -22,6 +22,7 @@ on: # yamllint disable-line rule:truthy - 'bridge-mockery-[0-9]*' - 'bridge-rector-[0-9]*' - 'bridge-symfony-console-[0-9]*' + - 'bridge-vcr-[0-9]*' - 'codecov-[0-9]*' - 'convention-[0-9]*' - 'data-[0-9]*' diff --git a/bridge/vcr/.github/workflows/close-prs.yml b/bridge/vcr/.github/workflows/close-prs.yml new file mode 100644 index 00000000..7640d59f --- /dev/null +++ b/bridge/vcr/.github/workflows/close-prs.yml @@ -0,0 +1,14 @@ +name: Close PRs + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + +jobs: + close: + uses: php-testo/gh-actions/.github/workflows/close-foreign-prs.yml@v1 + with: + upstream-url: https://github.com/php-testo/testo diff --git a/bridge/vcr/CHANGELOG.md b/bridge/vcr/CHANGELOG.md new file mode 100644 index 00000000..417f2d60 --- /dev/null +++ b/bridge/vcr/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +## Changelog diff --git a/bridge/vcr/README.md b/bridge/vcr/README.md new file mode 100644 index 00000000..f0408cc3 --- /dev/null +++ b/bridge/vcr/README.md @@ -0,0 +1,67 @@ +

+ TESTO +

+ +

PHP-VCR bridge

+ +
+ +[![Documentation](https://img.shields.io/badge/Documentation-blue?style=for-the-badge&logo=gitbook&logoColor=white)](https://php-testo.github.io) +[![Support on Boosty](https://img.shields.io/static/v1?style=for-the-badge&label=&message=Sponsorship&logo=Boosty&logoColor=white&color=%23F15F2C)](https://boosty.to/roxblnfk) + +
+ +
+ +> [!IMPORTANT] +> ## 🪞 This is a read-only mirror. +> +> Active development of the Testo project lives in [**php-testo/testo**](https://github.com/php-testo/testo) under `bridge/vcr/`. This repository is **automatically synchronized** from there on every release. +> +> File issues and pull requests in the [main monorepo](https://github.com/php-testo/testo/issues), not here. + +## About + +[PHP-VCR](https://github.com/php-vcr/php-vcr) integration for Testo. Mark any test with `#[VCR('cassette')]`: its HTTP interactions are recorded to a cassette on the first run and replayed from it afterwards, so the test stays fast, deterministic, and offline. The attribute is self-wiring — no plugin registration required. + +```php +use Testo\Attribute\Test; +use Testo\Bridge\VCR; +use Testo\Bridge\VCR\RecordMode; + +#[Test] +#[VCR('github-user', mode: RecordMode::None)] +public function fetches_a_user(): void +{ + $json = \file_get_contents('https://api.github.com/users/roxblnfk'); + // asserts... +} +``` + +Register `VcrPlugin` only to point php-vcr at a non-default cassette directory: + +```php +// testo.php +use Testo\Application\Config\ApplicationConfig; +use Testo\Application\Config\SuiteConfig; +use Testo\Bridge\VCR\VcrPlugin; + +return new ApplicationConfig( + plugins: [new VcrPlugin(cassettePath: __DIR__ . '/tests/fixtures')], + suites: [new SuiteConfig(name: 'Feature', location: ['tests/Feature'])], +); +``` + +## Install + +```bash +composer require --dev testo/bridge-vcr +``` + +[![PHP](https://img.shields.io/packagist/php-v/testo/bridge-vcr.svg?style=flat-square&logo=php)](https://packagist.org/packages/testo/bridge-vcr) +[![Latest Version on Packagist](https://img.shields.io/packagist/v/testo/bridge-vcr.svg?style=flat-square&logo=packagist)](https://packagist.org/packages/testo/bridge-vcr) +[![License](https://img.shields.io/packagist/l/testo/bridge-vcr.svg?style=flat-square)](https://github.com/php-testo/testo/blob/1.x/LICENSE.md) +[![Total Downloads](https://img.shields.io/packagist/dt/testo/bridge-vcr.svg?style=flat-square)](https://packagist.org/packages/testo/bridge-vcr/stats) diff --git a/bridge/vcr/VCR.php b/bridge/vcr/VCR.php new file mode 100644 index 00000000..54b652c3 --- /dev/null +++ b/bridge/vcr/VCR.php @@ -0,0 +1,72 @@ + + */ + public array $match; + + /** + * @param non-empty-string $name Cassette name — the interaction store PHP-VCR reads from and + * records to. Required. + * @param RecordMode|null $mode Record mode for this test; `null` inherits php-vcr's global default + * ({@see RecordMode::NewEpisodes}). + * @param list $match Request matchers for this test; an empty list inherits php-vcr's + * default (method + URL). + */ + public function __construct( + public string $name, + public ?RecordMode $mode = null, + array $match = [], + ) { + $this->match = \array_values($match); + } +} diff --git a/bridge/vcr/composer.json b/bridge/vcr/composer.json new file mode 100644 index 00000000..7d4e0c25 --- /dev/null +++ b/bridge/vcr/composer.json @@ -0,0 +1,56 @@ +{ + "name": "testo/bridge-vcr", + "description": "PHP-VCR bridge for the Testo testing framework.", + "license": "BSD-3-Clause", + "type": "library", + "keywords": [ + "testo", + "vcr", + "http", + "record", + "replay", + "testing" + ], + "authors": [ + { + "name": "Aleksei Gagarin (roxblnfk)", + "homepage": "https://github.com/roxblnfk" + } + ], + "funding": [ + { + "type": "boosty", + "url": "https://boosty.to/roxblnfk" + } + ], + "require": { + "php": ">=8.2", + "php-vcr/php-vcr": "^1.6", + "testo/testo": "0.10.36 - 1" + }, + "require-dev": { + "testo/assert": "^0.1.11", + "testo/codecov": "^0.1.11", + "testo/test": "^0.1.6" + }, + "autoload": { + "psr-4": { + "Testo\\Bridge\\VCR\\": "src/" + }, + "files": [ + "VCR.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Tests\\Bridge\\VCR\\": "tests/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + } +} diff --git a/bridge/vcr/src/Internal/VcrInterceptor.php b/bridge/vcr/src/Internal/VcrInterceptor.php new file mode 100644 index 00000000..7dce0471 --- /dev/null +++ b/bridge/vcr/src/Internal/VcrInterceptor.php @@ -0,0 +1,113 @@ +options->mode === null or $configuration->setMode($this->options->mode->value); + $this->options->match === [] or $configuration->enableRequestMatchers( + \array_map(static fn(Matcher $m): string => $m->value, $this->options->match), + ); + + self::$active = true; + PhpVcr::turnOn(); + PhpVcr::insertCassette($this->options->name); + try { + return self::runToCompletion($info, $next); + } finally { + PhpVcr::eject(); + PhpVcr::turnOff(); + self::$active = false; + } + } + + /** + * Run the test to completion without letting a suspension escape the VCR window. + * + * With no surrounding fiber (Testo's current, synchronous mode) the test runs inline. When Testo + * runs the test inside a fiber, `$next` is wrapped in a private fiber and resumed here until it + * terminates instead of re-suspending to the parent scheduler — keeping the process-global cassette + * window atomic. A `#[VCR]` test is therefore expected to be synchronous; awaiting real async work + * inside the window is unsupported by design. + * + * @param callable(TestInfo): TestResult $next + */ + private static function runToCompletion(TestInfo $info, callable $next): TestResult + { + if (\Fiber::getCurrent() === null) { + return $next($info); + } + + $fiber = new \Fiber(static fn(): TestResult => $next($info)); + $fiber->start(); + while (!$fiber->isTerminated()) { + $fiber->resume(); + } + + /** @var TestResult $result */ + $result = $fiber->getReturn(); + return $result; + } +} diff --git a/bridge/vcr/src/Matcher.php b/bridge/vcr/src/Matcher.php new file mode 100644 index 00000000..2946440e --- /dev/null +++ b/bridge/vcr/src/Matcher.php @@ -0,0 +1,42 @@ +value`. + * + * @api + */ +enum RecordMode: string +{ + /** + * Replay interactions already on the cassette; record any request not yet on it (making a real + * call for it). PHP-VCR's default. + */ + case NewEpisodes = 'new_episodes'; + + /** + * Record every interaction on the first run (when the cassette file does not exist yet), then + * replay-only on subsequent runs. + */ + case Once = 'once'; + + /** + * Replay only; a request with no recorded match throws. Best for CI — guarantees no test ever + * touches the network. + */ + case None = 'none'; +} diff --git a/bridge/vcr/src/VcrPlugin.php b/bridge/vcr/src/VcrPlugin.php new file mode 100644 index 00000000..ad370b91 --- /dev/null +++ b/bridge/vcr/src/VcrPlugin.php @@ -0,0 +1,43 @@ +cassettePath === null or PhpVcr::configure()->setCassettePath($this->cassettePath); + } +} diff --git a/bridge/vcr/tests/Acceptance/VcrReplayTest.php b/bridge/vcr/tests/Acceptance/VcrReplayTest.php new file mode 100644 index 00000000..328f7790 --- /dev/null +++ b/bridge/vcr/tests/Acceptance/VcrReplayTest.php @@ -0,0 +1,35 @@ +status, Status::Passed); + } + + public function unrecordedRequestInNoneModeFails(): void + { + $result = TestRunner::runTest([VcrScenarios::class, 'unrecordedRequestInNoneModeFails']); + Assert::same($result->status, Status::Error); + } + + public function untaggedTestPassesThrough(): void + { + $result = TestRunner::runTest([VcrScenarios::class, 'untaggedTestPassesThrough']); + Assert::same($result->status, Status::Passed); + } +} diff --git a/bridge/vcr/tests/Self/ClassLevelCassetteTest.php b/bridge/vcr/tests/Self/ClassLevelCassetteTest.php new file mode 100644 index 00000000..39f1f5d9 --- /dev/null +++ b/bridge/vcr/tests/Self/ClassLevelCassetteTest.php @@ -0,0 +1,44 @@ +