Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/.release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/split-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]*'
Expand Down
14 changes: 14 additions & 0 deletions bridge/vcr/.github/workflows/close-prs.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions bridge/vcr/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Changelog

## Changelog
67 changes: 67 additions & 0 deletions bridge/vcr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<p align="center">
<a href="https://github.com/php-testo/testo"><img alt="TESTO"
src="https://github.com/php-testo/.github/blob/1.x/resources/logo-full.svg?raw=true"
style="width: 2in; display: block"
/></a>
</p>

<p align="center">PHP-VCR bridge</p>

<div align="center">

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

</div>

<br />

> [!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)
72 changes: 72 additions & 0 deletions bridge/vcr/VCR.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

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
* test are recorded to a cassette on first run and replayed from it afterwards, so the test never
* touches the network again.
*
* ```php
* 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');
* // ...
* }
* ```
*
* 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.
*
* 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)]
#[FallbackInterceptor(VcrInterceptor::class)]
final readonly class VCR implements Interceptable
{
/**
* Request attributes that must match for a recording to be replayed.
*
* @var list<Matcher>
*/
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<Matcher> $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);
}
}
56 changes: 56 additions & 0 deletions bridge/vcr/composer.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
113 changes: 113 additions & 0 deletions bridge/vcr/src/Internal/VcrInterceptor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

namespace Testo\Bridge\VCR\Internal;

use Testo\Bridge\VCR;
use Testo\Bridge\VCR\Matcher;
use Testo\Core\Context\TestInfo;
use Testo\Core\Context\TestResult;
use Testo\Pipeline\Attribute\InterceptorOptions;
use Testo\Pipeline\Middleware\TestRunInterceptor;
use Testo\Pipeline\Policy\ConflictPolicy;
use VCR\VCR as PhpVcr;

/**
* 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 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 — 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,
onConflict: ConflictPolicy::Last,
)]
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;

public function __construct(
private readonly VCR $options,
) {}

#[\Override]
public function runTest(TestInfo $info, callable $next): TestResult
{
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();
$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($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;
}
}
42 changes: 42 additions & 0 deletions bridge/vcr/src/Matcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Testo\Bridge\VCR;

/**
* A request attribute that must line up between a recorded interaction and an outgoing request for
* PHP-VCR to replay the recording.
*
* Matchers are transport-agnostic: PHP-VCR normalizes every library hook (`stream_wrapper`, `curl`,
* `soap`) into one `\VCR\Request`, and matchers compare fields of that object — so the same set works
* regardless of which transport made the call. Two matchers are keyed to the request *shape* rather
* than the transport: {@see self::PostFields} (form-urlencoded bodies) and {@see self::SoapOperation}
* (the SOAP-ENV envelope). Pick the ones that fit the request.
*
* The backing value is the exact matcher name PHP-VCR's `Configuration::enableRequestMatchers()`
* expects.
*
* @api
*/
enum Matcher: string
{
case Method = 'method';

/** Full request URL, including path and query string. PHP-VCR has no separate "path" matcher. */
case Url = 'url';

case Host = 'host';
case QueryString = 'query_string';

/** Raw request body — the general choice for JSON/XML/SOAP payloads. */
case Body = 'body';

/** Parsed `application/x-www-form-urlencoded` fields; only meaningful for form POSTs. */
case PostFields = 'post_fields';

case Headers = 'headers';

/** SOAP operation parsed from the SOAP-ENV envelope; only meaningful for SOAP calls. */
case SoapOperation = 'soap_operation';
}
Loading
Loading