From 5590a5ff4d8459e72702710f82588590700fac6f Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sat, 15 Aug 2026 08:30:38 +0300 Subject: [PATCH 1/2] Let PROPERTY_DB point at Redis, not only at a directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core 0.3 shipped RedisCorpus — a corpus shared between CI and developers — and no suite could reach it. The engine reads no environment by design, so resolving where a corpus lives is this adapter's job, and this adapter hardcoded the filesystem one. PROPERTY_DB now takes redis://host[:port][/key-prefix] as well as a path, with the same parsing, defaults and messages as the Testo adapter, because the variable is one contract across both. ext-redis is preferred when loaded, predis otherwise, and neither installed throws rather than falling back: a suite told to share its corpus and quietly writing where nobody reads is worse than one that stops. --- AGENTS.md | 2 +- CHANGELOG.md | 10 ++++ README.md | 15 ++++- README.ru.md | 15 ++++- composer-require-checker.json | 19 +++++++ composer.json | 7 ++- llms.txt | 2 +- psalm.xml | 5 ++ src/PhpUnit/CorpusFromEnv.php | 88 +++++++++++++++++++++++++++++ src/PhpUnit/PropertyCheck.php | 3 +- src/PhpUnit/RedisDsn.php | 72 ++++++++++++++++++++++++ tests/CorpusFromEnvTest.php | 103 ++++++++++++++++++++++++++++++++++ tests/RedisDsnTest.php | 86 ++++++++++++++++++++++++++++ 13 files changed, 420 insertions(+), 7 deletions(-) create mode 100644 composer-require-checker.json create mode 100644 src/PhpUnit/CorpusFromEnv.php create mode 100644 src/PhpUnit/RedisDsn.php create mode 100644 tests/CorpusFromEnvTest.php create mode 100644 tests/RedisDsnTest.php diff --git a/AGENTS.md b/AGENTS.md index 31e26be..c553e69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ parity is golden rule 3. | `PROPERTY_RUNS` | Always (`false`/`''` = unset) | `/^\d+\z/`, `>= 1` | Overrides every property's run count, including `runs()` | `InvalidArgumentException` | | `PROPERTY_SEED` | Only when `seed()` was not called (explicit seed wins) | `/^-?\d+\z/` | Seeds every unseeded property; unset means a random seed per property | `InvalidArgumentException` | | `PROPERTY_VERBOSE` | Always | Any value except `''` and `'0'` enables | Attaches `VerboseListener`: every run's arguments/draws and each accepted shrink step | n/a (falsy values disable) | -| `PROPERTY_DB` | Always (`false`/`''` = off, nothing written) | Directory path (created on demand) | Regression corpus via `FilesystemCorpus::fromEnv()`: record on falsification, replay before the random phase, prune on green replay. An explicit `seed()` disables replay for that property | n/a | +| `PROPERTY_DB` | Always (`false`/`''` = off, nothing written) | Directory path (created on demand) **or** `redis://host[:port][/key-prefix]` | Regression corpus via `CorpusFromEnv::resolve()`: a path builds a `FilesystemCorpus`, a DSN a `RedisCorpus` (ext-redis preferred, else predis). An explicit `seed()` disables replay for that property | `InvalidArgumentException` — an unusable DSN, or no Redis client installed. Never a silent fall back to the filesystem | | `PROPERTY_PHASES` | Always (`false`/`''` = unset) | Comma-separated phase names, case-insensitive: `examples`, `corpus`, `random`, `shrink` | Stages of every run, in run order — **overrides** `phases()` | `InvalidArgumentException` naming the accepted values | | `PROPERTY_DERANDOMIZE` | Always | Any value except `''` and `'0'` enables | Derives every unset seed from the property id — **overrides** `derandomize()` | n/a (falsy values disable) | | `PROPERTY_PATH` | Only when `path()` was not called (explicit path wins) | A recorded `CounterExample::$path` | Replays that shrink descent instead of searching for it; needs the seed of the run that produced it | engine rejects a path that would be a silent no-op | diff --git a/CHANGELOG.md b/CHANGELOG.md index b882eaf..4bbfaa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- `PROPERTY_DB` now also takes a `redis://host[:port][/key-prefix]` DSN, which + builds core 0.3's `RedisCorpus`. Until now that class existed and no suite + could reach it: the engine reads no environment by design, and this adapter + hardcoded the filesystem corpus. A directory keeps meaning exactly what it + meant. `ext-redis` is preferred when loaded, `predis/predis` otherwise, and + neither installed is an error rather than a silent fall back to the + filesystem. Same variable, same messages as the Testo adapter. + ## 0.3.0 — 2026-08-15 - Added `PropertyCheck::edgeCases()` and `PROPERTY_EDGE_CASES` (`mixin` or diff --git a/README.md b/README.md index e545d74..76c1f57 100644 --- a/README.md +++ b/README.md @@ -159,12 +159,25 @@ Byte-for-byte parity with the Testo adapter — one contract across adapters: | `PROPERTY_RUNS` | Positive integer that overrides every property's run count (dial runs up in CI) | | `PROPERTY_SEED` | Integer seed for any property without an explicit `seed()` (replay a whole suite). An explicit `seed()` still wins | | `PROPERTY_VERBOSE` | Any value except `''`/`'0'` logs every run's generated arguments and each accepted shrink step | -| `PROPERTY_DB` | Directory path enabling the regression corpus. Unset means off, nothing is written | +| `PROPERTY_DB` | Directory path enabling the regression corpus, or a `redis://host[:port][/key-prefix]` DSN for a corpus shared between CI and developers. Unset means off, nothing is written | | `PROPERTY_PHASES` | Comma-separated stage list (`examples,corpus,random,shrink`, case-insensitive) that overrides `phases()` — an unknown name throws rather than skipping a stage. `examples,corpus` is the fast pull-request gate | | `PROPERTY_DERANDOMIZE` | Any value except `''`/`'0'` derives every unset seed from the property id, making a whole suite reproducible without editing it | | `PROPERTY_PATH` | A recorded shrink descent (`CounterExample::$path`) replayed instead of searched for. Needs the seed that produced it; an explicit `path()` wins | | `PROPERTY_EDGE_CASES` | `mixin` or `none` (case-insensitive) — the numeric boundary bias for the whole suite, overriding `edgeCases()`. An unknown value throws | +`PROPERTY_DB` takes either a directory or a Redis DSN: + +```bash +PROPERTY_DB=/tmp/corpus vendor/bin/phpunit # one machine +PROPERTY_DB=redis://127.0.0.1:6379 vendor/bin/phpunit # shared +PROPERTY_DB=redis://redis:6379/suite-a: vendor/bin/phpunit # shared server, own prefix +``` + +A directory remembers a counterexample for whoever owns it — in CI, a machine +deleted when the job ends. The Redis form is the same corpus, in the same +document, shared. It needs `ext-redis` or `predis/predis`; neither installed is +an error rather than a silent fall back to the filesystem. + The corpus format is exactly the one `rasuvaeff/property-testing` 2.8 wrote — a corpus recorded under Testo (or under 2.x) replays here and vice versa. On falsification the minimal input is recorded; the next run replays recorded diff --git a/README.ru.md b/README.ru.md index 1f6048b..3997ac6 100644 --- a/README.ru.md +++ b/README.ru.md @@ -159,12 +159,25 @@ $this->forAll(['values' => Gen::arrayOf(Gen::int())]) | `PROPERTY_RUNS` | Положительное целое, переопределяет число прогонов каждой property (поднять runs в CI) | | `PROPERTY_SEED` | Целочисленный seed для property без явного `seed()` (реплей всего suite). Явный `seed()` важнее | | `PROPERTY_VERBOSE` | Любое значение кроме `''`/`'0'` логирует аргументы каждого прогона и каждый принятый shrink-шаг | -| `PROPERTY_DB` | Путь к каталогу, включающий regression corpus. Не задана — выключено, ничего не пишется | +| `PROPERTY_DB` | Путь к каталогу, включающий регрессионный корпус, либо DSN `redis://host[:port][/key-prefix]` для корпуса, общего между CI и разработчиками. Не задан — выключен, ничего не пишется | | `PROPERTY_PHASES` | Список стадий через запятую (`examples,corpus,random,shrink`, регистр не важен), перекрывающий `phases()`; неизвестное имя — исключение, а не пропуск стадии. `examples,corpus` — быстрый гейт для pull request | | `PROPERTY_DERANDOMIZE` | Любое значение, кроме `''`/`'0'`, выводит каждый незаданный seed из id property: весь сьют становится воспроизводимым без правки кода | | `PROPERTY_PATH` | Записанный спуск shrink (`CounterExample::$path`) воспроизводится вместо повторного поиска. Нужен seed того прогона; явный `path()` побеждает | | `PROPERTY_EDGE_CASES` | `mixin` или `none` (регистр не важен) — граничное смещение для всего сьюта, перекрывает `edgeCases()`. Неизвестное значение — исключение | +`PROPERTY_DB` принимает либо каталог, либо Redis-DSN: + +```bash +PROPERTY_DB=/tmp/corpus vendor/bin/phpunit # одна машина +PROPERTY_DB=redis://127.0.0.1:6379 vendor/bin/phpunit # общий +PROPERTY_DB=redis://redis:6379/suite-a: vendor/bin/phpunit # общий сервер, свой префикс +``` + +Каталог помнит контрпример для того, кто им владеет, — в CI это машина, +которую удаляют вместе с job'ом. Redis-форма — тот же корпус в том же +документе, но общий. Нужен `ext-redis` или `predis/predis`; отсутствие обоих — +ошибка, а не тихий откат на файловую систему. + Формат корпуса — ровно тот, что писал `rasuvaeff/property-testing` 2.8: корпус, записанный под Testo (или под 2.x), реплеится здесь, и наоборот. При falsification записывается минимальный вход; следующий прогон реплеит diff --git a/composer-require-checker.json b/composer-require-checker.json new file mode 100644 index 0000000..016dd1c --- /dev/null +++ b/composer-require-checker.json @@ -0,0 +1,19 @@ +{ + "symbol-whitelist": [ + "Predis\\Client", + "Redis" + ], + "php-core-extensions": [ + "Core", + "date", + "json", + "hash", + "pcre", + "Phar", + "Reflection", + "SPL", + "random", + "standard" + ], + "scan-files": [] +} diff --git a/composer.json b/composer.json index 6fcd58e..1224a64 100644 --- a/composer.json +++ b/composer.json @@ -32,11 +32,16 @@ "friendsofphp/php-cs-fixer": "^3.95", "infection/infection": "^0.33 || ^0.34", "maglnet/composer-require-checker": "^4.17", + "predis/predis": "^2.2 || ^3.0", "rasuvaeff/rector-named-literals": "^1.0", "rector/rector": "^2.4", "roave/backward-compatibility-check": "^8.0", "vimeo/psalm": "^6.16" }, + "suggest": { + "ext-redis": "Required for PROPERTY_DB=redis://… — a regression corpus shared between CI and developers", + "predis/predis": "Required for PROPERTY_DB=redis://… when ext-redis is unavailable" + }, "autoload": { "psr-4": { "Rasuvaeff\\PropertyTesting\\": "src/" @@ -79,7 +84,7 @@ "@rector", "@bc-check" ], - "require-checker": "composer-require-checker check composer.json", + "require-checker": "composer-require-checker check composer.json --config-file=composer-require-checker.json", "test": "phpunit", "test:coverage": "phpunit --coverage-clover=build/coverage.xml", "test:coverage:ci": "phpunit --coverage-clover=build/coverage.xml" diff --git a/llms.txt b/llms.txt index d31f152..9d303f2 100644 --- a/llms.txt +++ b/llms.txt @@ -109,7 +109,7 @@ final class SortPropertyTest extends TestCase | `PROPERTY_RUNS` | `/^\d+\z/`, >= 1 | Overrides every property's run count; invalid value throws `InvalidArgumentException` | | `PROPERTY_SEED` | `/^-?\d+\z/` | Seeds properties without an explicit `seed()`; `seed()` wins; invalid value throws | | `PROPERTY_VERBOSE` | anything but `''`/`'0'` | Trace: each run's arguments/draws, each accepted shrink step | -| `PROPERTY_DB` | directory path | Regression corpus: record on falsification, replay first on the next run, prune on green; format = property-testing 2.8 | +| `PROPERTY_DB` | directory path OR `redis://host[:port][/key-prefix]` | Regression corpus: record on falsification, replay first on the next run, prune on green. A DSN builds a RedisCorpus (ext-redis preferred, else predis; neither installed THROWS) — same document as the directory form, shared between CI and developers | | `PROPERTY_PHASES` | `examples,corpus,random,shrink` (comma-separated, case-insensitive) | Stages to run; OVERRIDES `phases()`; unknown name throws | | `PROPERTY_DERANDOMIZE` | anything but `''`/`'0'` | Unset seeds derived from the property id; `seed()`/`PROPERTY_SEED` still win | | `PROPERTY_PATH` | recorded `CounterExample::$path` | Replays that shrink descent; needs the seed of that run; explicit `path()` wins | diff --git a/psalm.xml b/psalm.xml index e10ac79..45edfd9 100644 --- a/psalm.xml +++ b/psalm.xml @@ -41,4 +41,9 @@ + + + + diff --git a/src/PhpUnit/CorpusFromEnv.php b/src/PhpUnit/CorpusFromEnv.php new file mode 100644 index 0000000..546e6b9 --- /dev/null +++ b/src/PhpUnit/CorpusFromEnv.php @@ -0,0 +1,88 @@ +prefix); + } + + /** + * A client for the DSN, preferring `ext-redis` when it is loaded because + * it needs no autoloaded dependency at all. + * + * Neither available is a configuration error, not a silent fall back to + * the filesystem: a suite told to share its corpus and quietly writing to + * a directory nobody reads is worse than one that stops. + */ + private static function client(RedisDsn $dsn, string $raw): CorpusClient + { + if (extension_loaded('redis')) { + $redis = new \Redis(); + $redis->connect($dsn->host, $dsn->port); + + return new PhpRedisCorpusClient($redis); + } + + if (class_exists(\Predis\Client::class)) { + return new PredisCorpusClient(new \Predis\Client($dsn->toPredisParameters())); + } + + throw new \InvalidArgumentException(sprintf( + 'PROPERTY_DB="%s" needs a Redis client: install ext-redis or require predis/predis', + $raw, + )); + } + +} diff --git a/src/PhpUnit/PropertyCheck.php b/src/PhpUnit/PropertyCheck.php index 647e9d2..002caa4 100644 --- a/src/PhpUnit/PropertyCheck.php +++ b/src/PhpUnit/PropertyCheck.php @@ -12,7 +12,6 @@ use Rasuvaeff\PropertyTesting\Runner\CallableTrialExecutor; use Rasuvaeff\PropertyTesting\Runner\CoverageFailed; use Rasuvaeff\PropertyTesting\Runner\EdgeCases; -use Rasuvaeff\PropertyTesting\Runner\FilesystemCorpus; use Rasuvaeff\PropertyTesting\Runner\GaveUp; use Rasuvaeff\PropertyTesting\Runner\Passed; use Rasuvaeff\PropertyTesting\Runner\Phase; @@ -354,7 +353,7 @@ public function check(\Closure $property): void $definition, new CallableTrialExecutor($property), $listeners, - FilesystemCorpus::fromEnv(), + CorpusFromEnv::resolve(), ); $statistics = match (true) { diff --git a/src/PhpUnit/RedisDsn.php b/src/PhpUnit/RedisDsn.php new file mode 100644 index 0000000..c6e5e48 --- /dev/null +++ b/src/PhpUnit/RedisDsn.php @@ -0,0 +1,72 @@ + 'tcp', 'host' => $this->host, 'port' => $this->port]; + } + + /** + * @param string $dsn The value of `PROPERTY_DB`, already known to start with `redis://`. + */ + public static function parse(string $dsn): self + { + $parts = parse_url($dsn); + $host = is_array($parts) ? ($parts['host'] ?? null) : null; + + if (!is_string($host) || $host === '') { + throw new \InvalidArgumentException(sprintf( + 'PROPERTY_DB="%s" is not a usable Redis DSN; expected redis://host[:port][/key-prefix]', + $dsn, + )); + } + + $port = is_array($parts) ? ($parts['port'] ?? null) : null; + $path = is_array($parts) ? ($parts['path'] ?? null) : null; + $prefix = is_string($path) ? ltrim($path, '/') : ''; + + return new self( + host: $host, + port: is_int($port) ? $port : self::DEFAULT_PORT, + prefix: $prefix === '' ? self::DEFAULT_PREFIX : $prefix, + ); + } +} diff --git a/tests/CorpusFromEnvTest.php b/tests/CorpusFromEnvTest.php new file mode 100644 index 0000000..1b2514b --- /dev/null +++ b/tests/CorpusFromEnvTest.php @@ -0,0 +1,103 @@ +getValue($corpus); + self::assertSame('suite-a:', $prefix); + } finally { + $restore(); + } + } + + public function testAnUnusableDsnSurfacesAsAConfigurationError(): void + { + $restore = Env::set('PROPERTY_DB', 'redis://'); + + try { + CorpusFromEnv::resolve(); + + self::fail('expected an InvalidArgumentException'); + } catch (\InvalidArgumentException $e) { + self::assertStringContainsString('not a usable Redis DSN', $e->getMessage()); + } finally { + $restore(); + } + } +} diff --git a/tests/RedisDsnTest.php b/tests/RedisDsnTest.php new file mode 100644 index 0000000..7ca1e10 --- /dev/null +++ b/tests/RedisDsnTest.php @@ -0,0 +1,86 @@ +host); + self::assertSame($port, $parsed->port); + self::assertSame($prefix, $parsed->prefix); + } + + /** + * @return iterable + */ + public static function dsnProvider(): iterable + { + yield 'host only' => ['redis://127.0.0.1', '127.0.0.1', 6379, 'property-testing:corpus:']; + yield 'host and port' => ['redis://redis:6380', 'redis', 6380, 'property-testing:corpus:']; + yield 'prefix in the path' => ['redis://redis:6380/suite-a:', 'redis', 6380, 'suite-a:']; + yield 'prefix without a port' => ['redis://redis/suite-b:', 'redis', 6379, 'suite-b:']; + yield 'trailing slash is not a prefix' => ['redis://redis/', 'redis', 6379, 'property-testing:corpus:']; + yield 'nested path keeps its separators' => ['redis://redis/team/suite:', 'redis', 6379, 'team/suite:']; + } + + public function testTheConnectionParametersAreTheOnesPredisTakes(): void + { + // Asserted here because at the call site the same literal could only be + // checked by connecting to a server. + self::assertSame(['scheme' => 'tcp', 'host' => 'redis', 'port' => 6380], RedisDsn::parse('redis://redis:6380/suite:')->toPredisParameters()); + } + + public function testADsnWithoutAHostIsAConfigurationError(): void + { + try { + RedisDsn::parse('redis://'); + + self::fail('expected an InvalidArgumentException'); + } catch (\InvalidArgumentException $e) { + self::assertSame('PROPERTY_DB="redis://" is not a usable Redis DSN; expected redis://host[:port][/key-prefix]', $e->getMessage()); + } + } + + #[DataProvider('malformedProvider')] + public function testAMalformedDsnIsAConfigurationError(string $dsn): void + { + // Each of these makes parse_url() return false outright. The message + // still has to quote the value, because it came from an environment + // variable somebody typed by hand. + try { + RedisDsn::parse($dsn); + + self::fail('expected an InvalidArgumentException'); + } catch (\InvalidArgumentException $e) { + self::assertStringContainsString($dsn, $e->getMessage()); + self::assertStringContainsString('expected redis://host[:port][/key-prefix]', $e->getMessage()); + } + } + + /** + * @return iterable + */ + public static function malformedProvider(): iterable + { + yield 'no host at all' => ['redis://']; + yield 'port without a host' => ['redis://:6379']; + yield 'prefix without a host' => ['redis:///prefix:']; + yield 'port that is not a number' => ['redis://host:notaport']; + } +} From 6e4a9513dbe6405cf6557022a34b10670c507b0a Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sat, 15 Aug 2026 08:36:46 +0300 Subject: [PATCH 2/2] Connect on first use, not on resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as the Testo adapter, for the same reason: CI installs ext-redis and runs no Redis server, so an eager connect() in the resolver made every job red, while the composer image passed because it has no extension and predis is lazy by construction. The phpredis client is wrapped so the socket opens on the first recall or write, and two tests keep it honest — resolving a DSN returns without a server, and the documented preference is asserted in whichever environment the suite runs in. --- src/PhpUnit/CorpusFromEnv.php | 8 ++-- src/PhpUnit/LazyPhpRedisCorpusClient.php | 56 ++++++++++++++++++++++++ tests/CorpusFromEnvTest.php | 36 +++++++++++++++ 3 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 src/PhpUnit/LazyPhpRedisCorpusClient.php diff --git a/src/PhpUnit/CorpusFromEnv.php b/src/PhpUnit/CorpusFromEnv.php index 546e6b9..2a94087 100644 --- a/src/PhpUnit/CorpusFromEnv.php +++ b/src/PhpUnit/CorpusFromEnv.php @@ -7,7 +7,6 @@ use Rasuvaeff\PropertyTesting\Runner\Corpus; use Rasuvaeff\PropertyTesting\Runner\FilesystemCorpus; use Rasuvaeff\PropertyTesting\Runner\Redis\CorpusClient; -use Rasuvaeff\PropertyTesting\Runner\Redis\PhpRedisCorpusClient; use Rasuvaeff\PropertyTesting\Runner\Redis\PredisCorpusClient; use Rasuvaeff\PropertyTesting\Runner\RedisCorpus; @@ -69,10 +68,9 @@ public static function resolve(): ?Corpus private static function client(RedisDsn $dsn, string $raw): CorpusClient { if (extension_loaded('redis')) { - $redis = new \Redis(); - $redis->connect($dsn->host, $dsn->port); - - return new PhpRedisCorpusClient($redis); + // Lazily: resolving PROPERTY_DB must not open a socket, or a suite + // that names a corpus it never touches fails at startup. + return new LazyPhpRedisCorpusClient($dsn); } if (class_exists(\Predis\Client::class)) { diff --git a/src/PhpUnit/LazyPhpRedisCorpusClient.php b/src/PhpUnit/LazyPhpRedisCorpusClient.php new file mode 100644 index 0000000..25bcff7 --- /dev/null +++ b/src/PhpUnit/LazyPhpRedisCorpusClient.php @@ -0,0 +1,56 @@ +client()->get($key); + } + + #[\Override] + public function compareAndSet(string $key, ?string $expected, ?string $document): bool + { + return $this->client()->compareAndSet($key, $expected, $document); + } + + private function client(): PhpRedisCorpusClient + { + if ($this->client instanceof PhpRedisCorpusClient) { + return $this->client; + } + + $redis = new \Redis(); + $redis->connect($this->dsn->host, $this->dsn->port); + + return $this->client = new PhpRedisCorpusClient($redis); + } +} diff --git a/tests/CorpusFromEnvTest.php b/tests/CorpusFromEnvTest.php index 1b2514b..aef8a49 100644 --- a/tests/CorpusFromEnvTest.php +++ b/tests/CorpusFromEnvTest.php @@ -7,8 +7,10 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Rasuvaeff\PropertyTesting\PhpUnit\CorpusFromEnv; +use Rasuvaeff\PropertyTesting\PhpUnit\LazyPhpRedisCorpusClient; use Rasuvaeff\PropertyTesting\PhpUnit\Tests\Support\Env; use Rasuvaeff\PropertyTesting\Runner\FilesystemCorpus; +use Rasuvaeff\PropertyTesting\Runner\Redis\PredisCorpusClient; use Rasuvaeff\PropertyTesting\Runner\RedisCorpus; /** @@ -57,6 +59,40 @@ public function testAPathIsStillADirectoryCorpus(): void } } + public function testResolvingADsnNeverOpensASocket(): void + { + // The reason the phpredis client is wrapped: CI installs ext-redis and + // runs no Redis, and an eager connect() made every job red. + $restore = Env::set('PROPERTY_DB', 'redis://127.0.0.1:6399/never-touched:'); + + try { + self::assertInstanceOf(RedisCorpus::class, CorpusFromEnv::resolve()); + } finally { + $restore(); + } + } + + public function testExtRedisIsPreferredWhenItIsLoaded(): void + { + // The documented preference, asserted in whichever environment this + // runs: CI has the extension, the composer image does not. + $restore = Env::set('PROPERTY_DB', 'redis://127.0.0.1:6399'); + + try { + $corpus = CorpusFromEnv::resolve(); + self::assertInstanceOf(RedisCorpus::class, $corpus); + + $client = (new \ReflectionProperty($corpus, 'client'))->getValue($corpus); + + self::assertInstanceOf( + extension_loaded('redis') ? LazyPhpRedisCorpusClient::class : PredisCorpusClient::class, + $client, + ); + } finally { + $restore(); + } + } + public function testARedisDsnIsASharedCorpus(): void { $restore = Env::set('PROPERTY_DB', 'redis://127.0.0.1:6379');