From de265e5e1f67222b091e2f9bf494cbad0f4fa56b Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 15 Jul 2026 00:20:43 +0400 Subject: [PATCH 01/10] feat(bridge-vcr): scaffold PHP-VCR bridge with #[VCR] attribute Starter layout for testo/bridge-vcr: the `\Testo\Bridge\VCR` attribute (cassette name only for now), `VcrPlugin`, and a minimal `VcrInterceptor` that inserts/ejects the cassette around each tagged test. Concurrency isolation for VCR global state is left as a documented TODO. Assisted-By: Claude Fable 5 --- bridge/vcr/.github/workflows/close-prs.yml | 14 ++++ bridge/vcr/CHANGELOG.md | 3 + bridge/vcr/README.md | 64 +++++++++++++++++ bridge/vcr/composer.json | 53 +++++++++++++++ bridge/vcr/src/VCR.php | 39 +++++++++++ .../vcr/src/Vcr/Internal/VcrInterceptor.php | 68 +++++++++++++++++++ bridge/vcr/src/Vcr/VcrPlugin.php | 38 +++++++++++ bridge/vcr/tests/suites.php | 25 +++++++ 8 files changed, 304 insertions(+) create mode 100644 bridge/vcr/.github/workflows/close-prs.yml create mode 100644 bridge/vcr/CHANGELOG.md create mode 100644 bridge/vcr/README.md create mode 100644 bridge/vcr/composer.json create mode 100644 bridge/vcr/src/VCR.php create mode 100644 bridge/vcr/src/Vcr/Internal/VcrInterceptor.php create mode 100644 bridge/vcr/src/Vcr/VcrPlugin.php create mode 100644 bridge/vcr/tests/suites.php 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..53d86727 --- /dev/null +++ b/bridge/vcr/README.md @@ -0,0 +1,64 @@ +

+ 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. Register `VcrPlugin` and 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. + +```php +// testo.php +use Testo\Application\Config\ApplicationConfig; +use Testo\Application\Config\SuiteConfig; +use Testo\Bridge\Vcr\VcrPlugin; + +return new ApplicationConfig( + plugins: [new VcrPlugin()], + suites: [new SuiteConfig(name: 'Feature', location: ['tests/Feature'])], +); +``` + +```php +use Testo\Attribute\Test; +use Testo\Bridge\VCR; + +#[Test] +#[VCR('github-user')] +public function fetches_a_user(): void +{ + $json = \file_get_contents('https://api.github.com/users/roxblnfk'); + // asserts... +} +``` + +## 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/composer.json b/bridge/vcr/composer.json new file mode 100644 index 00000000..aa670529 --- /dev/null +++ b/bridge/vcr/composer.json @@ -0,0 +1,53 @@ +{ + "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\\": "src/" + } + }, + "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/VCR.php b/bridge/vcr/src/VCR.php new file mode 100644 index 00000000..9b8e4850 --- /dev/null +++ b/bridge/vcr/src/VCR.php @@ -0,0 +1,39 @@ +name); + try { + return $next($info); + } finally { + PhpVcr::eject(); + PhpVcr::turnOff(); + } + } + + /** + * Resolve the effective cassette for the test: the method-level {@see VCR} attribute wins, + * otherwise the class-level one, otherwise none. + */ + private static function resolveCassette(TestInfo $info): ?VCR + { + $method = $info->testDefinition->reflection->getAttributes(VCR::class); + if ($method !== []) { + return $method[0]->newInstance(); + } + + $class = $info->caseInfo->definition->reflection?->getAttributes(VCR::class) ?? []; + return $class === [] ? null : $class[0]->newInstance(); + } +} diff --git a/bridge/vcr/src/Vcr/VcrPlugin.php b/bridge/vcr/src/Vcr/VcrPlugin.php new file mode 100644 index 00000000..b9f60d3e --- /dev/null +++ b/bridge/vcr/src/Vcr/VcrPlugin.php @@ -0,0 +1,38 @@ +get(InterceptorCollector::class)->addInterceptor(new VcrInterceptor()); + } +} diff --git a/bridge/vcr/tests/suites.php b/bridge/vcr/tests/suites.php new file mode 100644 index 00000000..404d0819 --- /dev/null +++ b/bridge/vcr/tests/suites.php @@ -0,0 +1,25 @@ + Date: Wed, 15 Jul 2026 00:29:02 +0400 Subject: [PATCH 02/10] chore(bridge-vcr): wire release publishing for the new bridge Register testo/bridge-vcr in the release/split pipeline so its mirror repo (php-testo/bridge-vcr) is populated and versioned like every other bridge: - split-publish.yml: publish on `bridge-vcr-*` tags. - release-please config + manifest (seeded at 0.0.0 for the first release). - root composer.json: path-repo dev-alias and test autoload namespace. Assisted-By: Claude Fable 5 --- .github/.release-please-config.json | 6 ++++++ .github/workflows/split-publish.yml | 1 + composer.json | 4 +++- resources/version.json | 3 ++- 4 files changed, 12 insertions(+), 2 deletions(-) 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/composer.json b/composer.json index 3fa9e73f..ab93f440 100644 --- a/composer.json +++ b/composer.json @@ -88,6 +88,7 @@ "Tests\\Bridge\\Mockery\\": "bridge/mockery/tests/", "Tests\\Bridge\\Rector\\": "bridge/rector/tests/", "Tests\\Bridge\\SymfonyConsole\\": "bridge/symfony-console/tests/", + "Tests\\Bridge\\Vcr\\": "bridge/vcr/tests/", "Tests\\Codecov\\": "plugin/codecov/tests/", "Tests\\Convention\\": "plugin/convention/tests/", "Tests\\Data\\": "plugin/data/tests/", @@ -133,7 +134,8 @@ "testo/bridge-infection": "0.1.x-dev", "testo/bridge-mockery": "0.1.x-dev", "testo/bridge-rector": "0.2.x-dev", - "testo/bridge-symfony-console": "0.1.x-dev" + "testo/bridge-symfony-console": "0.1.x-dev", + "testo/bridge-vcr": "0.1.x-dev" } } } diff --git a/resources/version.json b/resources/version.json index cbcf334f..80309289 100644 --- a/resources/version.json +++ b/resources/version.json @@ -15,5 +15,6 @@ "bridge/symfony-console": "0.1.8", "bridge/infection": "0.1.8", "bridge/mockery": "0.1.1", - "bridge/rector": "0.2.0" + "bridge/rector": "0.2.0", + "bridge/vcr": "0.0.0" } From dee71a89702bbbfadf2558fee9a083190a739ce3 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 15 Jul 2026 00:44:18 +0400 Subject: [PATCH 03/10] feat(bridge-vcr): add per-test mode and request matchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the #[VCR] attribute with `mode` (RecordMode enum: NewEpisodes/Once/ None) and `match` (list: method/url/host/query_string/body/ post_fields/headers/soap_operation), applied per test via VCR::configure() before the cassette is inserted. The enum is named `Matcher` because `Match` is a reserved keyword; there is no `path` matcher (Url covers it). Matchers are transport-agnostic โ€” php-vcr normalizes curl/stream/soap into one Request โ€” so this stays one attribute, not one per transport. The interceptor keeps ORDER_CLOSE_TO_TEST, which sits inside retry/repeat (each attempt re-inserts the cassette) and outside lifecycle hooks. Global- state isolation under concurrency remains a documented NOTE/TODO. Assisted-By: Claude Fable 5 --- bridge/vcr/src/VCR.php | 30 ++++++++++--- .../vcr/src/Vcr/Internal/VcrInterceptor.php | 29 +++++++++---- bridge/vcr/src/Vcr/Matcher.php | 42 +++++++++++++++++++ bridge/vcr/src/Vcr/RecordMode.php | 34 +++++++++++++++ 4 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 bridge/vcr/src/Vcr/Matcher.php create mode 100644 bridge/vcr/src/Vcr/RecordMode.php diff --git a/bridge/vcr/src/VCR.php b/bridge/vcr/src/VCR.php index 9b8e4850..599e9c23 100644 --- a/bridge/vcr/src/VCR.php +++ b/bridge/vcr/src/VCR.php @@ -4,6 +4,9 @@ namespace Testo\Bridge; +use Testo\Bridge\Vcr\Matcher; +use Testo\Bridge\Vcr\RecordMode; + /** * Marks a test (or a whole test case) as replayed through PHP-VCR: HTTP interactions made during the * test are recorded to a cassette on first run and replayed from it afterwards, so the test never @@ -11,8 +14,10 @@ * * ```php * use Testo\Bridge\VCR; + * use Testo\Bridge\Vcr\Matcher; + * use Testo\Bridge\Vcr\RecordMode; * - * #[VCR('github-user')] + * #[VCR('github-user', mode: RecordMode::None, match: [Matcher::Method, Matcher::Url, Matcher::Body])] * public function testFetchesUser(): void * { * $json = \file_get_contents('https://api.github.com/users/roxblnfk'); @@ -20,8 +25,8 @@ * } * ``` * - * Placed on a class, it becomes the default cassette for every test in that case; a method-level - * attribute overrides it. + * Placed on a class, it becomes the default for every test in that case; a method-level attribute + * overrides it. * * Requires {@see \Testo\Bridge\Vcr\VcrPlugin} to be registered in the suite. * @@ -31,9 +36,24 @@ final readonly class VCR { /** - * @param string $name Cassette name. The interaction store PHP-VCR reads from and records to. + * Request attributes that must match for a recording to be replayed. + * + * @var list + */ + public array $match; + + /** + * @param string $name Cassette name โ€” the interaction store PHP-VCR reads from and records to. + * @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/src/Vcr/Internal/VcrInterceptor.php b/bridge/vcr/src/Vcr/Internal/VcrInterceptor.php index d88fd9bf..fad5ce54 100644 --- a/bridge/vcr/src/Vcr/Internal/VcrInterceptor.php +++ b/bridge/vcr/src/Vcr/Internal/VcrInterceptor.php @@ -4,6 +4,7 @@ namespace Testo\Bridge\Vcr\Internal; +use Testo\Bridge\Vcr\Matcher; use Testo\Bridge\VCR; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; @@ -14,15 +15,23 @@ /** * Inserts the cassette named by a test's (or its case's) {@see VCR} attribute for the duration of the - * test, then ejects it. Tests without the attribute pass straight through. + * test, applying that test's record mode and request matchers, then ejects it. Tests without the + * attribute pass straight through. * - * Runs innermost so the cassette is active as close as possible to the test body. + * Ordering ({@see InterceptorOptions::ORDER_CLOSE_TO_TEST}) places this *inside* the retry and repeat + * interceptors (which sit at low orders, far from the test) and *outside* the lifecycle hooks (which + * run innermost). So each retry / repeat attempt re-enters here and gets a fresh cassette, and HTTP + * made from `#[BeforeTest]` / `#[AfterTest]` hooks is covered too. * - * NOTE (concurrency): PHP-VCR is process-global static state โ€” one active cassette per process, plus - * a global library-hook install. Under Testo's fiber-based concurrency, sibling tests would clobber - * each other's cassette. The starter implementation does not yet isolate this; the intended approach - * (drive the test inside its own fiber so its VCR window never leaks across a suspension, ร  la the - * Mockery bridge's container guard) is described in SPEC.md and tracked as a TODO. + * NOTE (concurrency): PHP-VCR is process-global static state โ€” one active cassette per process, one + * global hook install, and a global mode/matcher configuration. Under Testo's fiber-based concurrency + * this bridge does NOT yet isolate that: while a cassette is active, a *concurrently scheduled* test's + * HTTP (even an untagged one) is intercepted against it, and a second `#[VCR]` test would clash. The + * canonical guard used elsewhere (see {@see \Testo\Assert\Internal\Middleware\AssertCollectorInterceptor}) + * swaps thread-local state on every `Fiber::suspend`/`resume`, but VCR's per-test state is a + * disk-backed cassette whose playback position would reset on re-insert โ€” so the swap is subtler here. + * The intended fix (exclusive scheduling of VCR tests, or a validated suspend/resume guard) is tracked + * in SPEC.md ยง5. * * @internal * @psalm-internal Testo\Bridge\Vcr @@ -41,6 +50,12 @@ public function runTest(TestInfo $info, callable $next): TestResult return $next($info); } + $configuration = PhpVcr::configure(); + $cassette->mode === null or $configuration->setMode($cassette->mode->value); + $cassette->match === [] or $configuration->enableRequestMatchers( + \array_map(static fn(Matcher $m): string => $m->value, $cassette->match), + ); + PhpVcr::turnOn(); PhpVcr::insertCassette($cassette->name); try { diff --git a/bridge/vcr/src/Vcr/Matcher.php b/bridge/vcr/src/Vcr/Matcher.php new file mode 100644 index 00000000..707231d1 --- /dev/null +++ b/bridge/vcr/src/Vcr/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'; +} From c0a6acfb4425f9f3a8911ac436dabcaf24d0567c Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 15 Jul 2026 00:52:03 +0400 Subject: [PATCH 04/10] feat(bridge-vcr): make the VCR window exclusive and non-yielding php-vcr is process-global, so a #[VCR] test must own the single cassette exclusively. The interceptor now contains the test in a private fiber and drives it to completion, absorbing any suspension instead of propagating it to the parent scheduler, so the turnOn..turnOff window never yields control and no sibling can clobber the active cassette. A process-wide lock brackets the window and fails loudly if two #[VCR] windows ever overlap. Testo respects fibers but does not schedule tests concurrently itself, so this is future-proofing; a #[VCR] test is synchronous by design. Documented on the #[VCR] attribute. Assisted-By: Claude Fable 5 --- bridge/vcr/src/VCR.php | 24 ++++--- .../vcr/src/Vcr/Internal/VcrInterceptor.php | 66 +++++++++++++++---- 2 files changed, 70 insertions(+), 20 deletions(-) diff --git a/bridge/vcr/src/VCR.php b/bridge/vcr/src/VCR.php index 599e9c23..aafafb8a 100644 --- a/bridge/vcr/src/VCR.php +++ b/bridge/vcr/src/VCR.php @@ -13,21 +13,27 @@ * touches the network again. * * ```php - * use Testo\Bridge\VCR; - * use Testo\Bridge\Vcr\Matcher; - * use Testo\Bridge\Vcr\RecordMode; + * use Testo\Bridge\VCR; + * use Testo\Bridge\Vcr\Matcher; + * use Testo\Bridge\Vcr\RecordMode; * - * #[VCR('github-user', mode: RecordMode::None, match: [Matcher::Method, Matcher::Url, Matcher::Body])] - * public function testFetchesUser(): void - * { - * $json = \file_get_contents('https://api.github.com/users/roxblnfk'); - * // ... - * } + * #[VCR('github-user', mode: RecordMode::None, match: [Matcher::Method, Matcher::Url, Matcher::Body])] + * public function testFetchesUser(): void + * { + * $json = \file_get_contents('https://api.github.com/users/roxblnfk'); + * // ... + * } * ``` * * Placed on a class, it becomes the default for every test in that case; a method-level attribute * overrides it. * + * **A VCR-tagged test runs as an exclusive, synchronous block.** PHP-VCR is process-global (one active + * cassette for the whole process), so while the cassette is inserted the test does not yield to the + * fiber scheduler and no other test may run concurrently โ€” the window is driven to completion and + * locked. Consequently a `#[VCR]` test must be synchronous: awaiting real async work inside it is + * unsupported, and two `#[VCR]` tests can never overlap. + * * Requires {@see \Testo\Bridge\Vcr\VcrPlugin} to be registered in the suite. * * @api diff --git a/bridge/vcr/src/Vcr/Internal/VcrInterceptor.php b/bridge/vcr/src/Vcr/Internal/VcrInterceptor.php index fad5ce54..696d47ff 100644 --- a/bridge/vcr/src/Vcr/Internal/VcrInterceptor.php +++ b/bridge/vcr/src/Vcr/Internal/VcrInterceptor.php @@ -23,15 +23,16 @@ * run innermost). So each retry / repeat attempt re-enters here and gets a fresh cassette, and HTTP * made from `#[BeforeTest]` / `#[AfterTest]` hooks is covered too. * - * NOTE (concurrency): PHP-VCR is process-global static state โ€” one active cassette per process, one - * global hook install, and a global mode/matcher configuration. Under Testo's fiber-based concurrency - * this bridge does NOT yet isolate that: while a cassette is active, a *concurrently scheduled* test's - * HTTP (even an untagged one) is intercepted against it, and a second `#[VCR]` test would clash. The - * canonical guard used elsewhere (see {@see \Testo\Assert\Internal\Middleware\AssertCollectorInterceptor}) - * swaps thread-local state on every `Fiber::suspend`/`resume`, but VCR's per-test state is a - * disk-backed cassette whose playback position would reset on re-insert โ€” so the swap is subtler here. - * The intended fix (exclusive scheduling of VCR tests, or a validated suspend/resume guard) is tracked - * in SPEC.md ยง5. + * ## Concurrency: the VCR window is exclusive and non-yielding + * + * PHP-VCR is process-global static state โ€” one active cassette, one global hook install, one + * mode/matcher configuration. It cannot be shared by two tests at once. Testo respects fibers but does + * not schedule tests concurrently itself; to stay correct if it ever does, this interceptor runs the + * test **fully contained in its own fiber and drives it to completion without propagating any + * suspension to the parent scheduler** ({@see self::runToCompletion()}). The `turnOn โ€ฆ turnOff` window + * therefore never yields control, so no sibling test can touch the global cassette while it is active. + * A process-wide lock ({@see self::$active}) enforces the invariant and fails loudly if a `#[VCR]` + * window is ever entered while another is already open. * * @internal * @psalm-internal Testo\Bridge\Vcr @@ -40,8 +41,15 @@ order: InterceptorOptions::ORDER_CLOSE_TO_TEST, testType: TestType::Test, )] -final readonly class VcrInterceptor implements TestRunInterceptor +final class VcrInterceptor implements TestRunInterceptor { + /** + * Guards the process-global php-vcr window: only one `#[VCR]` test may hold the cassette at a time. + * Structurally, {@see self::runToCompletion()} never yields the window, so under cooperative + * fibers this can never be contended โ€” it exists to fail loudly if that invariant is ever broken. + */ + private static bool $active = false; + #[\Override] public function runTest(TestInfo $info, callable $next): TestResult { @@ -50,22 +58,58 @@ public function runTest(TestInfo $info, callable $next): TestResult return $next($info); } + self::$active and throw new \RuntimeException( + 'A php-vcr cassette is already active in this process. #[VCR] tests run against a single ' + . 'global cassette and cannot overlap; do not trigger one #[VCR] test from within another.', + ); + $configuration = PhpVcr::configure(); $cassette->mode === null or $configuration->setMode($cassette->mode->value); $cassette->match === [] or $configuration->enableRequestMatchers( \array_map(static fn(Matcher $m): string => $m->value, $cassette->match), ); + self::$active = true; PhpVcr::turnOn(); PhpVcr::insertCassette($cassette->name); try { - return $next($info); + 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. + * + * When there is no surrounding fiber (Testo's current, synchronous mode) the test simply runs + * inline. When Testo runs the test inside a fiber, we wrap `$next` in a private fiber and resume it + * ourselves until it terminates instead of re-suspending to the parent scheduler. This keeps the + * process-global cassette window atomic: it never hands control back mid-test, so a sibling can + * never observe or clobber the active cassette. 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; + } + /** * Resolve the effective cassette for the test: the method-level {@see VCR} attribute wins, * otherwise the class-level one, otherwise none. From 80827dc5eb69b63307fe11b619ada41c5c97705f Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 15 Jul 2026 01:12:30 +0400 Subject: [PATCH 05/10] test(bridge-vcr): cover replay, status mapping and class-level default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a cassette-path option to VcrPlugin and the bridge's test suites: - Acceptance: a #[VCR(mode: None)] test replays a committed cassette with no network access. - Self: a class-level #[VCR] applies to every method, proving both the class fallback and that the exclusive window is released between sequential tests. - Feature: status mapping via TestRunner โ€” replay hit passes, an unrecorded request in None mode is Error, an untagged test passes through untouched. Validated locally against php-vcr's Symfony 8 branch (feat/441-symfony-8); the integrated run wires in once php-vcr tags a Symfony 8 release. Assisted-By: Claude Fable 5 --- bridge/vcr/src/Vcr/VcrPlugin.php | 13 +++++- bridge/vcr/tests/Acceptance/VcrReplayTest.php | 35 +++++++++++++++ bridge/vcr/tests/Feature/VcrStatusTest.php | 45 +++++++++++++++++++ .../vcr/tests/Self/ClassLevelCassetteTest.php | 34 ++++++++++++++ bridge/vcr/tests/Stub/VcrScenarios.php | 40 +++++++++++++++++ bridge/vcr/tests/fixtures/hello.yml | 13 ++++++ bridge/vcr/tests/suites.php | 12 ++++- 7 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 bridge/vcr/tests/Acceptance/VcrReplayTest.php create mode 100644 bridge/vcr/tests/Feature/VcrStatusTest.php create mode 100644 bridge/vcr/tests/Self/ClassLevelCassetteTest.php create mode 100644 bridge/vcr/tests/Stub/VcrScenarios.php create mode 100644 bridge/vcr/tests/fixtures/hello.yml diff --git a/bridge/vcr/src/Vcr/VcrPlugin.php b/bridge/vcr/src/Vcr/VcrPlugin.php index b9f60d3e..9fdb6587 100644 --- a/bridge/vcr/src/Vcr/VcrPlugin.php +++ b/bridge/vcr/src/Vcr/VcrPlugin.php @@ -8,6 +8,7 @@ use Testo\Bridge\Vcr\Internal\VcrInterceptor; use Testo\Common\PluginConfigurator; use Testo\Pipeline\InterceptorCollector; +use VCR\VCR as PhpVcr; /** * Plugin that drives PHP-VCR for tests annotated with {@see \Testo\Bridge\VCR}. @@ -18,7 +19,7 @@ * ```php * // testo.php * return new ApplicationConfig( - * plugins: [new VcrPlugin()], + * plugins: [new VcrPlugin(cassettePath: __DIR__ . '/tests/fixtures')], * suites: [new SuiteConfig(name: 'Feature', location: ['tests/Feature'])], * ); * ``` @@ -30,9 +31,19 @@ */ final readonly class VcrPlugin implements PluginConfigurator { + /** + * @param string|null $cassettePath Directory PHP-VCR reads and writes cassettes in. `null` keeps + * php-vcr's default (`tests/fixtures`, relative to the working directory). The directory + * must exist. This is process-global in php-vcr, so the last configured suite wins. + */ + public function __construct( + private ?string $cassettePath = null, + ) {} + #[\Override] public function configure(Container $container): void { + $this->cassettePath === null or PhpVcr::configure()->setCassettePath($this->cassettePath); $container->get(InterceptorCollector::class)->addInterceptor(new VcrInterceptor()); } } diff --git a/bridge/vcr/tests/Acceptance/VcrReplayTest.php b/bridge/vcr/tests/Acceptance/VcrReplayTest.php new file mode 100644 index 00000000..7cb73c87 --- /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..0f995102 --- /dev/null +++ b/bridge/vcr/tests/Self/ClassLevelCassetteTest.php @@ -0,0 +1,34 @@ + Date: Wed, 15 Jul 2026 02:21:30 +0400 Subject: [PATCH 06/10] chore(bridge-vcr): wire the bridge into the root test build Add testo/bridge-vcr (+ php-vcr) to the root require-dev and register its suites in testo.php, so the bridge's tests run in CI alongside the others. Until php-vcr tags a Symfony 8 release (php-vcr#442), composer resolves this by downgrading Symfony to 7.x; re-run CI once that release lands for a clean Symfony 8 run. Assisted-By: Claude Fable 5 --- composer.json | 2 ++ testo.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/composer.json b/composer.json index ab93f440..17036b73 100644 --- a/composer.json +++ b/composer.json @@ -56,11 +56,13 @@ "buggregator/trap": "^1.10", "internal/dload": "^1.6", "llm/skills": "^1.3", + "php-vcr/php-vcr": "^1.6", "roxblnfk/unpoly": "1.8.2", "rector/rector": "^2.5.2", "testo/bridge-infection": "^0.1.8", "testo/bridge-mockery": "^0.1.1", "testo/bridge-rector": "^0.2.0", + "testo/bridge-vcr": "^0.1", "testo/facade": "^0.1.1" }, "suggest": { diff --git a/testo.php b/testo.php index 697257d6..7bda0a3d 100644 --- a/testo.php +++ b/testo.php @@ -14,6 +14,7 @@ 'bridge/mockery/tests', 'bridge/rector/tests', 'bridge/symfony-console/tests', + 'bridge/vcr/tests', 'plugin/assert/tests', 'plugin/bench/tests', 'plugin/codecov/tests', @@ -49,6 +50,7 @@ require 'bridge/mockery/tests/suites.php', require 'bridge/rector/tests/suites.php', require 'bridge/symfony-console/tests/suites.php', + require 'bridge/vcr/tests/suites.php', require 'plugin/assert/tests/suites.php', require 'plugin/bench/tests/suites.php', require 'plugin/codecov/tests/suites.php', From eeb7ac027e0b2972b9bad97fd0b4bbd00e69a4da Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 15 Jul 2026 22:12:05 +0400 Subject: [PATCH 07/10] refactor(bridge-vcr): make #[VCR] self-wiring via Interceptable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model #[VCR] on the retry attribute: it now implements Interceptable and declares #[FallbackInterceptor(VcrInterceptor)], so the interceptor is wired into the pipeline (at its own order) only for tagged tests, receiving the resolved VCR instance via the constructor โ€” no global registration and no per-test reflection. Class-vs-method precedence is handled by ConflictPolicy::Last (class = default, method overrides). VcrPlugin is now optional: it only sets a custom cassette path. #[VCR] works without it. Adds a method-overrides-class test (+ other.yml fixture); all 7 bridge tests pass locally. Assisted-By: Claude Fable 5 --- bridge/vcr/src/VCR.php | 10 ++- .../vcr/src/Vcr/Internal/VcrInterceptor.php | 76 ++++++++----------- bridge/vcr/src/Vcr/VcrPlugin.php | 12 +-- .../vcr/tests/Self/ClassLevelCassetteTest.php | 10 +++ bridge/vcr/tests/fixtures/other.yml | 13 ++++ 5 files changed, 65 insertions(+), 56 deletions(-) create mode 100644 bridge/vcr/tests/fixtures/other.yml diff --git a/bridge/vcr/src/VCR.php b/bridge/vcr/src/VCR.php index aafafb8a..1f35c0a1 100644 --- a/bridge/vcr/src/VCR.php +++ b/bridge/vcr/src/VCR.php @@ -4,8 +4,11 @@ namespace Testo\Bridge; +use Testo\Bridge\Vcr\Internal\VcrInterceptor; use Testo\Bridge\Vcr\Matcher; use Testo\Bridge\Vcr\RecordMode; +use Testo\Pipeline\Attribute\FallbackInterceptor; +use Testo\Pipeline\Attribute\Interceptable; /** * Marks a test (or a whole test case) as replayed through PHP-VCR: HTTP interactions made during the @@ -34,12 +37,15 @@ * locked. Consequently a `#[VCR]` test must be synchronous: awaiting real async work inside it is * unsupported, and two `#[VCR]` tests can never overlap. * - * Requires {@see \Testo\Bridge\Vcr\VcrPlugin} to be registered in the suite. + * The attribute is self-wiring: it is {@see Interceptable}, so {@see VcrInterceptor} is inserted into + * the pipeline (at its own order) only for tests that carry it โ€” no plugin registration needed. + * Register {@see \Testo\Bridge\Vcr\VcrPlugin} only to point php-vcr at a non-default cassette path. * * @api */ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] -final readonly class VCR +#[FallbackInterceptor(VcrInterceptor::class)] +final readonly class VCR implements Interceptable { /** * Request attributes that must match for a recording to be replayed. diff --git a/bridge/vcr/src/Vcr/Internal/VcrInterceptor.php b/bridge/vcr/src/Vcr/Internal/VcrInterceptor.php index 696d47ff..ef0aa5ec 100644 --- a/bridge/vcr/src/Vcr/Internal/VcrInterceptor.php +++ b/bridge/vcr/src/Vcr/Internal/VcrInterceptor.php @@ -8,38 +8,41 @@ use Testo\Bridge\VCR; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; -use Testo\Core\Value\TestType; use Testo\Pipeline\Attribute\InterceptorOptions; use Testo\Pipeline\Middleware\TestRunInterceptor; +use Testo\Pipeline\Policy\ConflictPolicy; use VCR\VCR as PhpVcr; /** - * Inserts the cassette named by a test's (or its case's) {@see VCR} attribute for the duration of the - * test, applying that test's record mode and request matchers, then ejects it. Tests without the - * attribute pass straight through. + * Handles the {@see VCR} attribute: inserts the named cassette for the duration of the test, applying + * the attribute's record mode and request matchers, then ejects it. + * + * Wired in automatically by the {@see VCR} attribute (it is `Interceptable` with a + * {@see \Testo\Pipeline\Attribute\FallbackInterceptor}), so this only ever runs for tagged tests and + * receives the resolved {@see VCR} instance via the constructor โ€” no per-test reflection. When a test + * carries both a class-level and a method-level `#[VCR]`, {@see ConflictPolicy::Last} keeps the + * method's (class = default, method overrides). * * Ordering ({@see InterceptorOptions::ORDER_CLOSE_TO_TEST}) places this *inside* the retry and repeat - * interceptors (which sit at low orders, far from the test) and *outside* the lifecycle hooks (which - * run innermost). So each retry / repeat attempt re-enters here and gets a fresh cassette, and HTTP - * made from `#[BeforeTest]` / `#[AfterTest]` hooks is covered too. + * interceptors (which sit far from the test) and *outside* the lifecycle hooks, so each retry/repeat + * attempt re-enters here with a fresh cassette and HTTP from `#[BeforeTest]`/`#[AfterTest]` is covered. * * ## Concurrency: the VCR window is exclusive and non-yielding * * PHP-VCR is process-global static state โ€” one active cassette, one global hook install, one - * mode/matcher configuration. It cannot be shared by two tests at once. Testo respects fibers but does - * not schedule tests concurrently itself; to stay correct if it ever does, this interceptor runs the - * test **fully contained in its own fiber and drives it to completion without propagating any - * suspension to the parent scheduler** ({@see self::runToCompletion()}). The `turnOn โ€ฆ turnOff` window - * therefore never yields control, so no sibling test can touch the global cassette while it is active. - * A process-wide lock ({@see self::$active}) enforces the invariant and fails loudly if a `#[VCR]` - * window is ever entered while another is already open. + * mode/matcher configuration โ€” so it cannot be shared by two tests at once. Testo respects fibers but + * does not schedule tests concurrently itself; to stay correct if it ever does, the test runs **fully + * contained in its own fiber, driven to completion without propagating any suspension to the parent + * scheduler** ({@see self::runToCompletion()}). The `turnOn โ€ฆ turnOff` window therefore never yields, + * so no sibling test can touch the global cassette while it is active. {@see self::$active} enforces + * the invariant and fails loudly if a `#[VCR]` window is entered while another is already open. * * @internal * @psalm-internal Testo\Bridge\Vcr */ #[InterceptorOptions( order: InterceptorOptions::ORDER_CLOSE_TO_TEST, - testType: TestType::Test, + onConflict: ConflictPolicy::Last, )] final class VcrInterceptor implements TestRunInterceptor { @@ -50,28 +53,27 @@ final class VcrInterceptor implements TestRunInterceptor */ private static bool $active = false; + public function __construct( + private readonly VCR $options, + ) {} + #[\Override] public function runTest(TestInfo $info, callable $next): TestResult { - $cassette = self::resolveCassette($info); - if ($cassette === null) { - return $next($info); - } - self::$active and throw new \RuntimeException( 'A php-vcr cassette is already active in this process. #[VCR] tests run against a single ' . 'global cassette and cannot overlap; do not trigger one #[VCR] test from within another.', ); $configuration = PhpVcr::configure(); - $cassette->mode === null or $configuration->setMode($cassette->mode->value); - $cassette->match === [] or $configuration->enableRequestMatchers( - \array_map(static fn(Matcher $m): string => $m->value, $cassette->match), + $this->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($cassette->name); + PhpVcr::insertCassette($this->options->name); try { return self::runToCompletion($info, $next); } finally { @@ -84,12 +86,11 @@ public function runTest(TestInfo $info, callable $next): TestResult /** * Run the test to completion without letting a suspension escape the VCR window. * - * When there is no surrounding fiber (Testo's current, synchronous mode) the test simply runs - * inline. When Testo runs the test inside a fiber, we wrap `$next` in a private fiber and resume it - * ourselves until it terminates instead of re-suspending to the parent scheduler. This keeps the - * process-global cassette window atomic: it never hands control back mid-test, so a sibling can - * never observe or clobber the active cassette. A `#[VCR]` test is therefore expected to be - * synchronous โ€” awaiting real async work inside the window is unsupported by design. + * 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 */ @@ -109,19 +110,4 @@ private static function runToCompletion(TestInfo $info, callable $next): TestRes $result = $fiber->getReturn(); return $result; } - - /** - * Resolve the effective cassette for the test: the method-level {@see VCR} attribute wins, - * otherwise the class-level one, otherwise none. - */ - private static function resolveCassette(TestInfo $info): ?VCR - { - $method = $info->testDefinition->reflection->getAttributes(VCR::class); - if ($method !== []) { - return $method[0]->newInstance(); - } - - $class = $info->caseInfo->definition->reflection?->getAttributes(VCR::class) ?? []; - return $class === [] ? null : $class[0]->newInstance(); - } } diff --git a/bridge/vcr/src/Vcr/VcrPlugin.php b/bridge/vcr/src/Vcr/VcrPlugin.php index 9fdb6587..5190a3b9 100644 --- a/bridge/vcr/src/Vcr/VcrPlugin.php +++ b/bridge/vcr/src/Vcr/VcrPlugin.php @@ -5,16 +5,14 @@ namespace Testo\Bridge\Vcr; use Internal\Container\Container; -use Testo\Bridge\Vcr\Internal\VcrInterceptor; use Testo\Common\PluginConfigurator; -use Testo\Pipeline\InterceptorCollector; use VCR\VCR as PhpVcr; /** - * Plugin that drives PHP-VCR for tests annotated with {@see \Testo\Bridge\VCR}. + * Optional plugin for the PHP-VCR bridge. * - * Register it in your {@see \Testo\Application\Config\ApplicationConfig} `$plugins` list (or on a - * single {@see \Testo\Application\Config\SuiteConfig}): + * The {@see \Testo\Bridge\VCR} attribute is self-wiring (it is `Interceptable`), so `#[VCR]` tests work + * without any plugin. Register this plugin only to point php-vcr at a non-default cassette directory: * * ```php * // testo.php @@ -24,9 +22,6 @@ * ); * ``` * - * Once registered, every test carrying `#[VCR('cassette')]` runs with the named cassette inserted: - * HTTP interactions are recorded on the first run and replayed on subsequent runs. - * * @api */ final readonly class VcrPlugin implements PluginConfigurator @@ -44,6 +39,5 @@ public function __construct( public function configure(Container $container): void { $this->cassettePath === null or PhpVcr::configure()->setCassettePath($this->cassettePath); - $container->get(InterceptorCollector::class)->addInterceptor(new VcrInterceptor()); } } diff --git a/bridge/vcr/tests/Self/ClassLevelCassetteTest.php b/bridge/vcr/tests/Self/ClassLevelCassetteTest.php index 0f995102..9e674f83 100644 --- a/bridge/vcr/tests/Self/ClassLevelCassetteTest.php +++ b/bridge/vcr/tests/Self/ClassLevelCassetteTest.php @@ -31,4 +31,14 @@ public function secondMethodUsesClassCassette(): void { Assert::same(\file_get_contents('https://api.example.test/hello'), '{"message":"hello from cassette"}'); } + + /** + * A method-level {@see VCR} overrides the class-level default (ConflictPolicy::Last), so this + * replays `other.yml` rather than the class's `hello.yml`. + */ + #[VCR('other.yml', mode: RecordMode::None)] + public function methodLevelOverridesClassDefault(): void + { + Assert::same(\file_get_contents('https://api.example.test/other'), '{"message":"override"}'); + } } diff --git a/bridge/vcr/tests/fixtures/other.yml b/bridge/vcr/tests/fixtures/other.yml new file mode 100644 index 00000000..9c764f58 --- /dev/null +++ b/bridge/vcr/tests/fixtures/other.yml @@ -0,0 +1,13 @@ +- + request: + method: GET + url: 'https://api.example.test/other' + headers: + Host: api.example.test + response: + status: + code: 200 + message: OK + headers: + Content-Type: application/json + body: '{"message":"override"}' From 63fedf93c011678a7ce435dd81ad5f1d061a9044 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 15 Jul 2026 22:14:11 +0400 Subject: [PATCH 08/10] docs(bridge-vcr): #[VCR] is self-wiring; VcrPlugin is optional Reflect the Interceptable refactor in the README: the attribute works without registering a plugin; VcrPlugin is only for a custom cassette path. Assisted-By: Claude Fable 5 --- bridge/vcr/README.md | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/bridge/vcr/README.md b/bridge/vcr/README.md index 53d86727..4a78962f 100644 --- a/bridge/vcr/README.md +++ b/bridge/vcr/README.md @@ -25,26 +25,15 @@ ## About -[PHP-VCR](https://github.com/php-vcr/php-vcr) integration for Testo. Register `VcrPlugin` and 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. - -```php -// testo.php -use Testo\Application\Config\ApplicationConfig; -use Testo\Application\Config\SuiteConfig; -use Testo\Bridge\Vcr\VcrPlugin; - -return new ApplicationConfig( - plugins: [new VcrPlugin()], - suites: [new SuiteConfig(name: 'Feature', location: ['tests/Feature'])], -); -``` +[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')] +#[VCR('github-user', mode: RecordMode::None)] public function fetches_a_user(): void { $json = \file_get_contents('https://api.github.com/users/roxblnfk'); @@ -52,6 +41,20 @@ public function fetches_a_user(): void } ``` +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 From ebc8dc7039133aa18c2a9df0f9111ec75e236fcd Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 15 Jul 2026 23:08:47 +0400 Subject: [PATCH 09/10] refactor(bridge-vcr): move #[VCR] to package root, namespace Testo\Bridge\VCR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow the package-level-attribute layout used by plugin/retry: the #[VCR] attribute lives at the package root (VCR.php) and is loaded via composer's `files` autoload, while everything else moves under the matching namespace Testo\Bridge\VCR\ (src/ โ†’ PSR-4 "Testo\\Bridge\\VCR\\"). So the src namespace and the attribute FQN are consistent (VCR, not the earlier mixed-case Vcr). Renames RecordMode/Matcher/VcrPlugin into src/ root and VcrInterceptor into src/Internal/; updates all namespaces, use statements, test namespaces (Tests\Bridge\VCR) and the root autoload-dev entry. All 7 bridge tests pass. Assisted-By: Claude Fable 5 --- bridge/vcr/README.md | 4 ++-- bridge/vcr/{src => }/VCR.php | 12 ++++++------ bridge/vcr/composer.json | 9 ++++++--- bridge/vcr/src/{Vcr => }/Internal/VcrInterceptor.php | 6 +++--- bridge/vcr/src/{Vcr => }/Matcher.php | 2 +- bridge/vcr/src/{Vcr => }/RecordMode.php | 2 +- bridge/vcr/src/{Vcr => }/VcrPlugin.php | 2 +- bridge/vcr/tests/Acceptance/VcrReplayTest.php | 8 ++++---- bridge/vcr/tests/Feature/VcrStatusTest.php | 8 ++++---- bridge/vcr/tests/Self/ClassLevelCassetteTest.php | 8 ++++---- bridge/vcr/tests/Stub/VcrScenarios.php | 4 ++-- bridge/vcr/tests/suites.php | 2 +- composer.json | 2 +- 13 files changed, 36 insertions(+), 33 deletions(-) rename bridge/vcr/{src => }/VCR.php (90%) rename bridge/vcr/src/{Vcr => }/Internal/VcrInterceptor.php (97%) rename bridge/vcr/src/{Vcr => }/Matcher.php (98%) rename bridge/vcr/src/{Vcr => }/RecordMode.php (96%) rename bridge/vcr/src/{Vcr => }/VcrPlugin.php (97%) diff --git a/bridge/vcr/README.md b/bridge/vcr/README.md index 4a78962f..f0408cc3 100644 --- a/bridge/vcr/README.md +++ b/bridge/vcr/README.md @@ -30,7 +30,7 @@ ```php use Testo\Attribute\Test; use Testo\Bridge\VCR; -use Testo\Bridge\Vcr\RecordMode; +use Testo\Bridge\VCR\RecordMode; #[Test] #[VCR('github-user', mode: RecordMode::None)] @@ -47,7 +47,7 @@ Register `VcrPlugin` only to point php-vcr at a non-default cassette directory: // testo.php use Testo\Application\Config\ApplicationConfig; use Testo\Application\Config\SuiteConfig; -use Testo\Bridge\Vcr\VcrPlugin; +use Testo\Bridge\VCR\VcrPlugin; return new ApplicationConfig( plugins: [new VcrPlugin(cassettePath: __DIR__ . '/tests/fixtures')], diff --git a/bridge/vcr/src/VCR.php b/bridge/vcr/VCR.php similarity index 90% rename from bridge/vcr/src/VCR.php rename to bridge/vcr/VCR.php index 1f35c0a1..d49fb5be 100644 --- a/bridge/vcr/src/VCR.php +++ b/bridge/vcr/VCR.php @@ -4,9 +4,9 @@ namespace Testo\Bridge; -use Testo\Bridge\Vcr\Internal\VcrInterceptor; -use Testo\Bridge\Vcr\Matcher; -use Testo\Bridge\Vcr\RecordMode; +use Testo\Bridge\VCR\Internal\VcrInterceptor; +use Testo\Bridge\VCR\Matcher; +use Testo\Bridge\VCR\RecordMode; use Testo\Pipeline\Attribute\FallbackInterceptor; use Testo\Pipeline\Attribute\Interceptable; @@ -17,8 +17,8 @@ * * ```php * use Testo\Bridge\VCR; - * use Testo\Bridge\Vcr\Matcher; - * use Testo\Bridge\Vcr\RecordMode; + * use Testo\Bridge\VCR\Matcher; + * use Testo\Bridge\VCR\RecordMode; * * #[VCR('github-user', mode: RecordMode::None, match: [Matcher::Method, Matcher::Url, Matcher::Body])] * public function testFetchesUser(): void @@ -39,7 +39,7 @@ * * The attribute is self-wiring: it is {@see Interceptable}, so {@see VcrInterceptor} is inserted into * the pipeline (at its own order) only for tests that carry it โ€” no plugin registration needed. - * Register {@see \Testo\Bridge\Vcr\VcrPlugin} only to point php-vcr at a non-default cassette path. + * Register {@see \Testo\Bridge\VCR\VcrPlugin} only to point php-vcr at a non-default cassette path. * * @api */ diff --git a/bridge/vcr/composer.json b/bridge/vcr/composer.json index aa670529..7d4e0c25 100644 --- a/bridge/vcr/composer.json +++ b/bridge/vcr/composer.json @@ -35,12 +35,15 @@ }, "autoload": { "psr-4": { - "Testo\\Bridge\\": "src/" - } + "Testo\\Bridge\\VCR\\": "src/" + }, + "files": [ + "VCR.php" + ] }, "autoload-dev": { "psr-4": { - "Tests\\Bridge\\Vcr\\": "tests/" + "Tests\\Bridge\\VCR\\": "tests/" } }, "minimum-stability": "dev", diff --git a/bridge/vcr/src/Vcr/Internal/VcrInterceptor.php b/bridge/vcr/src/Internal/VcrInterceptor.php similarity index 97% rename from bridge/vcr/src/Vcr/Internal/VcrInterceptor.php rename to bridge/vcr/src/Internal/VcrInterceptor.php index ef0aa5ec..7dce0471 100644 --- a/bridge/vcr/src/Vcr/Internal/VcrInterceptor.php +++ b/bridge/vcr/src/Internal/VcrInterceptor.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Testo\Bridge\Vcr\Internal; +namespace Testo\Bridge\VCR\Internal; -use Testo\Bridge\Vcr\Matcher; use Testo\Bridge\VCR; +use Testo\Bridge\VCR\Matcher; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Pipeline\Attribute\InterceptorOptions; @@ -38,7 +38,7 @@ * the invariant and fails loudly if a `#[VCR]` window is entered while another is already open. * * @internal - * @psalm-internal Testo\Bridge\Vcr + * @psalm-internal Testo\Bridge\VCR */ #[InterceptorOptions( order: InterceptorOptions::ORDER_CLOSE_TO_TEST, diff --git a/bridge/vcr/src/Vcr/Matcher.php b/bridge/vcr/src/Matcher.php similarity index 98% rename from bridge/vcr/src/Vcr/Matcher.php rename to bridge/vcr/src/Matcher.php index 707231d1..2946440e 100644 --- a/bridge/vcr/src/Vcr/Matcher.php +++ b/bridge/vcr/src/Matcher.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Vcr; +namespace Testo\Bridge\VCR; /** * A request attribute that must line up between a recorded interaction and an outgoing request for diff --git a/bridge/vcr/src/Vcr/RecordMode.php b/bridge/vcr/src/RecordMode.php similarity index 96% rename from bridge/vcr/src/Vcr/RecordMode.php rename to bridge/vcr/src/RecordMode.php index 7d93e20b..799b8732 100644 --- a/bridge/vcr/src/Vcr/RecordMode.php +++ b/bridge/vcr/src/RecordMode.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Vcr; +namespace Testo\Bridge\VCR; /** * How PHP-VCR treats the cassette while a {@see \Testo\Bridge\VCR}-tagged test runs. diff --git a/bridge/vcr/src/Vcr/VcrPlugin.php b/bridge/vcr/src/VcrPlugin.php similarity index 97% rename from bridge/vcr/src/Vcr/VcrPlugin.php rename to bridge/vcr/src/VcrPlugin.php index 5190a3b9..ad370b91 100644 --- a/bridge/vcr/src/Vcr/VcrPlugin.php +++ b/bridge/vcr/src/VcrPlugin.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Vcr; +namespace Testo\Bridge\VCR; use Internal\Container\Container; use Testo\Common\PluginConfigurator; diff --git a/bridge/vcr/tests/Acceptance/VcrReplayTest.php b/bridge/vcr/tests/Acceptance/VcrReplayTest.php index 7cb73c87..328f7790 100644 --- a/bridge/vcr/tests/Acceptance/VcrReplayTest.php +++ b/bridge/vcr/tests/Acceptance/VcrReplayTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Tests\Bridge\Vcr\Acceptance; +namespace Tests\Bridge\VCR\Acceptance; use Testo\Assert; -use Testo\Bridge\Vcr\Internal\VcrInterceptor; -use Testo\Bridge\Vcr\RecordMode; -use Testo\Bridge\Vcr\VcrPlugin; +use Testo\Bridge\VCR\Internal\VcrInterceptor; +use Testo\Bridge\VCR\RecordMode; +use Testo\Bridge\VCR\VcrPlugin; use Testo\Bridge\VCR; use Testo\Codecov\Covers; use Testo\Test; diff --git a/bridge/vcr/tests/Feature/VcrStatusTest.php b/bridge/vcr/tests/Feature/VcrStatusTest.php index 5788f10d..b3e8deb5 100644 --- a/bridge/vcr/tests/Feature/VcrStatusTest.php +++ b/bridge/vcr/tests/Feature/VcrStatusTest.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace Tests\Bridge\Vcr\Feature; +namespace Tests\Bridge\VCR\Feature; use Testo\Assert; -use Testo\Bridge\Vcr\Internal\VcrInterceptor; -use Testo\Bridge\Vcr\VcrPlugin; +use Testo\Bridge\VCR\Internal\VcrInterceptor; +use Testo\Bridge\VCR\VcrPlugin; use Testo\Bridge\VCR; use Testo\Codecov\Covers; use Testo\Core\Value\Status; use Testo\Test; use Testo\Testing\Attribute\TestingSuite; use Testo\Testing\Helper\TestRunner; -use Tests\Bridge\Vcr\Stub\VcrScenarios; +use Tests\Bridge\VCR\Stub\VcrScenarios; /** * How the VCR bridge maps onto Testo's test statuses. Each case runs a stub scenario through diff --git a/bridge/vcr/tests/Self/ClassLevelCassetteTest.php b/bridge/vcr/tests/Self/ClassLevelCassetteTest.php index 9e674f83..39f1f5d9 100644 --- a/bridge/vcr/tests/Self/ClassLevelCassetteTest.php +++ b/bridge/vcr/tests/Self/ClassLevelCassetteTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Tests\Bridge\Vcr\Self; +namespace Tests\Bridge\VCR\Self; use Testo\Assert; -use Testo\Bridge\Vcr\Internal\VcrInterceptor; -use Testo\Bridge\Vcr\RecordMode; -use Testo\Bridge\Vcr\VcrPlugin; +use Testo\Bridge\VCR\Internal\VcrInterceptor; +use Testo\Bridge\VCR\RecordMode; +use Testo\Bridge\VCR\VcrPlugin; use Testo\Bridge\VCR; use Testo\Codecov\Covers; use Testo\Test; diff --git a/bridge/vcr/tests/Stub/VcrScenarios.php b/bridge/vcr/tests/Stub/VcrScenarios.php index 3933ee52..22c85c70 100644 --- a/bridge/vcr/tests/Stub/VcrScenarios.php +++ b/bridge/vcr/tests/Stub/VcrScenarios.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Tests\Bridge\Vcr\Stub; +namespace Tests\Bridge\VCR\Stub; use Testo\Assert; -use Testo\Bridge\Vcr\RecordMode; +use Testo\Bridge\VCR\RecordMode; use Testo\Bridge\VCR; use Testo\Test; diff --git a/bridge/vcr/tests/suites.php b/bridge/vcr/tests/suites.php index 74bb1308..ce3a3398 100644 --- a/bridge/vcr/tests/suites.php +++ b/bridge/vcr/tests/suites.php @@ -5,7 +5,7 @@ use Testo\Application\Config\FinderConfig; use Testo\Application\Config\Plugin\SuitePlugins; use Testo\Application\Config\SuiteConfig; -use Testo\Bridge\Vcr\VcrPlugin; +use Testo\Bridge\VCR\VcrPlugin; return [ new SuiteConfig( diff --git a/composer.json b/composer.json index 17036b73..5c27ee97 100644 --- a/composer.json +++ b/composer.json @@ -90,7 +90,7 @@ "Tests\\Bridge\\Mockery\\": "bridge/mockery/tests/", "Tests\\Bridge\\Rector\\": "bridge/rector/tests/", "Tests\\Bridge\\SymfonyConsole\\": "bridge/symfony-console/tests/", - "Tests\\Bridge\\Vcr\\": "bridge/vcr/tests/", + "Tests\\Bridge\\VCR\\": "bridge/vcr/tests/", "Tests\\Codecov\\": "plugin/codecov/tests/", "Tests\\Convention\\": "plugin/convention/tests/", "Tests\\Data\\": "plugin/data/tests/", From 4289c1e3e27e182578fdc7e0efc365ce588e509f Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 17 Jul 2026 14:52:59 +0400 Subject: [PATCH 10/10] docs(VCR): clarify cassette name parameter as required non-empty string --- bridge/vcr/VCR.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bridge/vcr/VCR.php b/bridge/vcr/VCR.php index d49fb5be..54b652c3 100644 --- a/bridge/vcr/VCR.php +++ b/bridge/vcr/VCR.php @@ -55,7 +55,8 @@ public array $match; /** - * @param string $name Cassette name โ€” the interaction store PHP-VCR reads from and records to. + * @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