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 @@
+
+
+
+
+PHP-VCR bridge
+
+
+
+[](https://php-testo.github.io)
+[](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
+```
+
+[](https://packagist.org/packages/testo/bridge-vcr)
+[](https://packagist.org/packages/testo/bridge-vcr)
+[](https://github.com/php-testo/testo/blob/1.x/LICENSE.md)
+[](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 @@
+