diff --git a/.env.example b/.env.example index bc2cb334c..db6fbc3df 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,9 @@ # REDIS_PORT=6379 # REDIS_PASSWORD=password # REDIS_DB=1 +# REDIS_TEST_DB_MIN=1 +# REDIS_TEST_DB_MAX=14 +# REDIS_TEST_SECONDARY_DB=15 # Meilisearch Integration Tests # MEILISEARCH_HOST=127.0.0.1 diff --git a/.github/workflows/redis.yml b/.github/workflows/redis.yml index 1178996f0..a47047f30 100644 --- a/.github/workflows/redis.yml +++ b/.github/workflows/redis.yml @@ -59,6 +59,8 @@ jobs: env: REDIS_HOST: redis REDIS_PORT: 6379 + REDIS_DB: 0 + REDIS_TEST_SECONDARY_DB: 1 run: | vendor/bin/phpunit tests/Integration/Auth vendor/bin/phpunit tests/Integration/Cache/Redis @@ -116,6 +118,8 @@ jobs: env: REDIS_HOST: valkey REDIS_PORT: 6379 + REDIS_DB: 0 + REDIS_TEST_SECONDARY_DB: 1 run: | vendor/bin/phpunit tests/Integration/Auth vendor/bin/phpunit tests/Integration/Cache/Redis diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 15d781096..f1a2334c3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,7 +45,14 @@ jobs: run: | COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o - - name: Execute tests - run: | - PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run --diff - vendor/bin/phpunit -c phpunit.xml.dist + - name: Check code style + run: PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run --diff + + - name: Run framework test suite + run: vendor/bin/phpunit -c phpunit.xml.dist + + - name: Run Testbench package-mode suite + run: php src/testbench/bin/testbench package:test --parallel tests/Testbench + + - name: Run Testbench dogfood package suite + run: composer test:dogfood diff --git a/.gitignore b/.gitignore index b175467bf..4a413f2ac 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ /docs/.vuepress/.cache /docs/.vuepress/.temp /docs/reviews/ +/dogfood/testbench-package/.phpunit.cache/ +/dogfood/testbench-package/composer.lock +/dogfood/testbench-package/vendor/ /node_modules /src/*/node_modules /src/wayfinder/tests/.generated diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index e41784593..cb742112f 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -107,6 +107,7 @@ ->exclude('_archive') ->exclude('_tmp') ->exclude('bin') + ->exclude('dogfood/testbench-package/vendor') ->exclude('overrides') ->exclude('src/testbench/workbench/bootstrap/cache') ->exclude('src/testbench/workbench/runtime') diff --git a/AGENTS.md b/AGENTS.md index efa1d72c7..98cddbd90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,11 @@ After porting is complete, run phpstan on the newly ported package and fix error #### 5. Run the full test suite -**Always use `composer test:parallel`** to run the test suite. This invokes `bin/paratest` via a custom wrapper that configures per-worker isolation. Running `vendor/bin/paratest` directly bypasses this setup and will cause failures. +**Always use `composer test:parallel`** to run the full components test suite. This runs raw ParaTest with Hypervel's parallel testing flag supplied by `phpunit.xml.dist`. + +**Always use `composer test:testbench`** after Testbench changes. This runs the scoped Testbench package-mode contract suite through the real `package:test` command. + +ParaTest defaults to the machine CPU count when no process count is specified. Redis integration runs need `REDIS_TEST_DB_MIN` / `REDIS_TEST_DB_MAX` to cover the chosen worker count, or an explicit `--processes` / `-p` value that fits the configured range. Investigate all failures thoroughly — don't assume a failure is caused by the porting work without confirming it. For straightforward fixes (e.g. a missed namespace update), fix and continue. For anything more complex (behavioral changes, test logic issues, unclear root causes), STOP and explain the cause along with your recommended fix for approval. diff --git a/bin/paratest b/bin/paratest deleted file mode 100755 index b1b5d6d28..000000000 --- a/bin/paratest +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env php -load(); -} - -/** - * Read an env var from $_SERVER, getenv(), or fall back to default. - */ -function envVar(string $key, string $default): string -{ - return $_SERVER[$key] ?? (getenv($key) ?: $default); -} - -$baseDb = (int) envVar('REDIS_DB', '1'); -$maxProcesses = null; - -try { - $redis = new Redis(); - $redis->connect( - envVar('REDIS_HOST', '127.0.0.1'), - (int) envVar('REDIS_PORT', '6379') - ); - - $auth = envVar('REDIS_PASSWORD', ''); - if ($auth !== '') { - $redis->auth($auth); - } - - $config = $redis->config('GET', 'databases'); - $totalDbs = (int) ($config['databases'] ?? 16); - - // Workers use DBs base+1 through base+N, so available slots = totalDbs - base - 1 - $maxProcesses = $totalDbs - $baseDb - 1; - - if ($maxProcesses < 1) { - fwrite(STDERR, "Warning: Only {$totalDbs} Redis databases configured with base DB {$baseDb}. " - . "Running with 1 process. Increase 'databases' in redis.conf for more parallelism.\n"); - $maxProcesses = 1; - } - - // Flush the base DB (shared secondary for select() tests) before spawning workers - $redis->select($baseDb); - $redis->flushdb(); - - $redis->close(); -} catch (Throwable $e) { - // Redis unavailable — fall back to CPU count (non-Redis tests still benefit from parallelism) - fwrite(STDERR, "Note: Could not connect to Redis ({$e->getMessage()}). Using CPU count for process limit.\n"); -} - -// Determine process count: min(cpu_count, available_redis_dbs) -$cpuCount = (int) (shell_exec('nproc 2>/dev/null') ?: shell_exec('sysctl -n hw.ncpu 2>/dev/null') ?: '4'); -$processes = $maxProcesses !== null ? min($cpuCount, $maxProcesses) : $cpuCount; - -// Collect extra arguments (skip argv[0] which is this script) -$extraArgs = array_slice($argv, 1); - -// Check if --processes or -p was already specified -$hasProcessFlag = false; -foreach ($extraArgs as $arg) { - if ($arg === '--processes' || $arg === '-p' || str_starts_with($arg, '--processes=') || str_starts_with($arg, '-p=')) { - $hasProcessFlag = true; - break; - } -} - -$args = []; -if (! $hasProcessFlag) { - $args[] = '--processes'; - $args[] = (string) $processes; -} -$args = array_merge($args, $extraArgs); - -$paratest = __DIR__ . '/../vendor/bin/paratest'; -$command = escapeshellarg($paratest) . ' ' . implode(' ', array_map('escapeshellarg', $args)); - -fwrite(STDERR, "Running: paratest --processes {$processes}" . ($extraArgs ? ' ' . implode(' ', $extraArgs) : '') . "\n"); - -// Signal to worker processes that parallel testing infrastructure is active. -// Required by ParallelTesting::inParallel() — without this flag, parallel -// callbacks (TestDatabases, TestCaches, TestViews) are skipped even when -// TEST_TOKEN is present. Uses putenv() so the subprocess inherits it. -putenv('HYPERVEL_PARALLEL_TESTING=1'); - -passthru($command, $exitCode); -exit($exitCode); diff --git a/composer.json b/composer.json index d6bd29c27..19fb4eac6 100644 --- a/composer.json +++ b/composer.json @@ -26,9 +26,6 @@ }, "autoload": { "psr-4": { - "Workbench\\App\\": "src/testbench/workbench/app/", - "Workbench\\Database\\Factories\\": "src/testbench/workbench/database/factories/", - "Workbench\\Database\\Seeders\\": "src/testbench/workbench/database/seeders/", "Hypervel\\ApiClient\\": "src/api-client/src/", "Hypervel\\Contracts\\": "src/contracts/src/", "Hypervel\\Auth\\": "src/auth/src/", @@ -121,12 +118,19 @@ "tests/Database/Fixtures/MigrationCreatorFakeMigration.php" ], "psr-4": { + "Workbench\\App\\": "src/testbench/workbench/app/", + "Workbench\\Database\\Factories\\": "src/testbench/workbench/database/factories/", + "Workbench\\Database\\Seeders\\": "src/testbench/workbench/database/seeders/", "Hypervel\\Tests\\": "tests/" }, "classmap": [ "tests/Validation/Enums.php" ] }, + "bin": [ + "src/facade-documenter/facade.php", + "src/testbench/bin/testbench" + ], "require": { "php": ">=8.4", "ext-dom": "*", @@ -363,7 +367,13 @@ }, "scripts": { "test": "phpunit", - "test:parallel": "php bin/paratest", + "test:parallel": "paratest", + "test:testbench": "php src/testbench/bin/testbench package:test --parallel tests/Testbench", + "test:dogfood": [ + "Composer\\Config::disableProcessTimeout", + "composer --working-dir=dogfood/testbench-package install --prefer-dist -n -o", + "composer --working-dir=dogfood/testbench-package test" + ], "analyse": [ "phpstan analyse", "phpstan analyse -c phpstan.types.neon.dist" @@ -373,13 +383,17 @@ "facade": "php -f src/facade-documenter/facade.php --", "check": [ "@test", + "@test:testbench", + "@test:dogfood", "@analyse", "@lint" ], "fix": [ "@lint:fix", "@analyse", - "@test:parallel" + "@test:parallel", + "@test:testbench", + "@test:dogfood" ] }, "minimum-stability": "dev", diff --git a/docs/plans/2026-07-05-parallel-redis-runtime-isolation.md b/docs/plans/2026-07-05-parallel-redis-runtime-isolation.md new file mode 100644 index 000000000..2d8dc7b9b --- /dev/null +++ b/docs/plans/2026-07-05-parallel-redis-runtime-isolation.md @@ -0,0 +1,1264 @@ +# Parallel Redis Runtime Isolation + +## Goal + +Replace the components-only Redis preflight wrapper with one runtime Redis isolation model that works for framework tests, applications, Testbench packages, and components-style package monorepos. + +The final state should be boring to reason about: + +- `composer test:parallel` runs the components framework suite through raw ParaTest. +- Testbench package-mode behavior is covered by a scoped `test:testbench` script. +- No command or wrapper connects to Redis before the test suite starts. +- Redis is touched only by tests that actually use Hypervel's Redis testing support. +- Tests that use `InteractsWithRedis` receive a real Redis logical database per parallel worker. +- Tests that need `select()` across a second Redis database opt in with `REDIS_TEST_SECONDARY_DB`. +- Command-level `.env` parsing is not added. Package and app env loading stays in PHPUnit/Testbench/application bootstrap and the Testbench runtime clone. +- The Testbench command does not leak its temporary CLI application environment into PHPUnit or ParaTest subprocesses. + +Churn and backwards compatibility are not constraints. The final codebase should read as if this was the original testing design. + +## Current State + +`composer test:parallel` currently runs a repository-local wrapper: + +```json +{ + "scripts": { + "test:parallel": "php bin/paratest" + } +} +``` + +`bin/paratest` currently: + +- loads the components `.env` file +- connects to Redis before ParaTest starts +- probes `CONFIG GET databases` +- caps worker count from Redis logical database count +- flushes the base Redis database before workers start +- shells out to `vendor/bin/paratest` +- sets `HYPERVEL_PARALLEL_TESTING=1` + +That design works for the components repo, but it has the wrong owner. Redis setup belongs to tests that need Redis, not to a parent test-runner process. + +The current Testbench binary also has a separate correctness gap that becomes important once `package:test` is covered as a first-class contract. Upstream Orchestra defines `TESTBENCH_CORE` in its binary: + +```php +define('TESTBENCH_CORE', true); +``` + +Hypervel's `src/testbench/bin/testbench` does not. `is_testbench_cli()` checks that constant, so package commands are hidden from `testbench list`, and `PackageManifest::providersFromTestbench()` does not merge the package-under-test `extra.hypervel` metadata while running from the CLI. This should be fixed as part of making the Testbench binary the canonical path. + +The normal command path already exists: + +```php +// Hypervel\Testing\Console\TestCommandBase +$process = (new Process( + command: array_merge( + $this->binary(), + $parallel ? $this->paratestArguments($options) : $this->phpunitArguments($options), + ), + env: $parallel ? $this->paratestEnvironmentVariables() : $this->phpunitEnvironmentVariables(), +))->setTimeout(null); +``` + +For parallel runs it already injects: + +```php +[ + 'HYPERVEL_PARALLEL_TESTING' => 1, + 'HYPERVEL_PARALLEL_TESTING_RECREATE_DATABASES' => $this->option('recreate-databases'), + 'HYPERVEL_PARALLEL_TESTING_DROP_DATABASES' => $this->option('drop-databases'), + 'HYPERVEL_PARALLEL_TESTING_WITHOUT_DATABASES' => $this->option('without-databases'), + 'HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE' => $this->option('without-cache'), +] +``` + +`Hypervel\Testbench\Foundation\Console\TestCommand` extends that command and uses the package root plus the Testbench parallel runner: + +```php +protected function parallelRunner(): string +{ + return ParallelRunner::class; +} + +protected function basePath(string ...$paths): string +{ + return package_path(...$paths); +} +``` + +So `vendor/bin/testbench package:test --parallel` is the correct shared command path for package-style suites. It should not be used as the full components framework-suite gate because it intentionally activates package-tester semantics, including Testbench skeleton env/config defaults. + +The command path still passes the Testbench CLI application's runtime environment to workers through `defined_environment_variables()` and then adds `HYPERVEL_PARALLEL_TESTING`, `TESTBENCH_PACKAGE_TESTER`, and `TESTBENCH_WORKING_PATH`. That broad forwarding is the wrong ownership boundary. The command should forward declared package-test channels, not every value the temporary CLI app happened to load. Package and app env loading belongs in PHPUnit, Testbench application bootstrap, and the per-worker runtime clone. + +The shared test command already tries to prevent parent app env leakage before spawning child processes: + +```php +protected function clearEnv(): void +{ + if ($this->option('env')) { + return; + } + + $path = $this->hypervel->environmentPath(); + + if (! is_string($path)) { + return; + } + + $variables = self::getEnvironmentVariables($path, $this->hypervel->environmentFile()); + $repository = Env::getRepository(); + + foreach ($variables as $name) { + $repository->clear($name); + } +} +``` + +In Hypervel this does not work reliably. `TestCommandBase::handle()` calls `Env::enablePutenv()` before `clearEnv()`, which resets the cached repository. The fresh immutable dotenv writer did not load the keys from the parent runtime `.env`, so `Repository::clear()` treats those keys as externally defined and refuses to delete them. Since Hypervel enables putenv support by default, those values can remain in `$_ENV`, `$_SERVER`, and the real process environment. Symfony Process then inherits them even when the command passes an explicit env array. + +The fix is to use Hypervel's direct adapter deletion helper: + +```php +$variables = self::getEnvironmentVariables($path, $this->hypervel->environmentFile()); + +Env::getRepository(); +Env::deleteMany($variables); +``` + +`Env::getRepository()` rebuilds the adapter list after `Env::enablePutenv()`. `Env::deleteMany()` deletes from `$_ENV`, `$_SERVER`, and all tracked adapters directly, including the putenv adapter. This fixes the shared `TestCommandBase`, so both app `artisan test` and Testbench `package:test` get the corrected behavior. + +`ParallelTesting::token()` is the framework runtime API for the worker token: + +```php +public function token(): string|false +{ + return $this->tokenResolver + ? call_user_func($this->tokenResolver) + : ($_SERVER['TEST_TOKEN'] ?? false); +} +``` + +Redis worker allocation should use this API rather than reading `env('TEST_TOKEN')` directly so tests and framework code that install a token resolver continue to work. + +## Research Notes + +### Redis tests that need a secondary database + +Only a small number of tests need cross-database `select()` behavior: + +- `tests/Integration/Redis/RedisProxyIntegrationTest.php` + - `testPipelineCallbackAndSelect` + - `testPipelineCallbackAndPipeline` + - `testSelectIsolationAcrossCoroutines` +- `tests/Integration/Redis/RedisConnectionIntegrationTest.php` + - verifies direct phpredis `select()` works on the current primary test database +- `tests/Foundation/Testing/Concerns/InteractsWithRedisParallelTest.php` + - currently tests helper calculations + +Many Redis tests need real database isolation because they use `flushdb()`, scans, keys, raw Redis commands, or low-level Redis behavior. Prefix-only isolation is not enough for those tests. A Redis logical database per worker is the right default for Hypervel's Redis testing helpers. + +### Current `InteractsWithRedis` + +`InteractsWithRedis` currently computes: + +```php +protected function getBaseRedisDb(): int +{ + return (int) env('REDIS_DB', 0); +} + +protected function getParallelRedisDb(): int +{ + $token = env('TEST_TOKEN'); + + return $this->getBaseRedisDb() + ($token !== null ? (int) $token : 0); +} +``` + +It also reserves `REDIS_DB` as a shared secondary database during parallel runs, then uses `REDIS_DB + TEST_TOKEN` for workers. Sequential runs use `REDIS_DB` as primary and `REDIS_DB + 1` as secondary. + +That makes every sequential Redis test implicitly use two logical Redis databases when the secondary helper is called, even though only a few tests need that behavior. + +The full raw ParaTest run also exposed `tests/Integration/Horizon/worker.php` as a Redis allocation consumer. Horizon's integration tests spawn that executable bootstrap in a real subprocess so the worker can process queued jobs while the parent test process supervises it. The bootstrap had its own copy of the old `REDIS_DB + TEST_TOKEN` formula. After the parent test app moved to the new min/max allocator, token `1` pushed jobs to database `1` while the subprocess watched database `2`, leaving jobs `pending`. This is duplicated allocation logic drifting across consumers, so the allocation math should live in one testing helper used by both `InteractsWithRedis` and the Horizon worker bootstrap. + +### Why not command-level env parsing + +The abandoned preflight design needed the command to parse package `.env` and PHPUnit XML because it tried to connect to Redis before PHPUnit/Testbench booted the suite. + +Removing Redis preflight removes that need. + +Env loading should stay in the normal runtime path: + +- shell/process env and PHPUnit `` / `` values are handled by PHPUnit and process inheritance +- package/workbench `.env` values are copied into each Testbench runtime clone and loaded by Testbench application bootstrap +- Testbench YAML `env:` values are explicit package-test configuration and are forwarded by `package:test` +- Hypervel application env is handled by the framework bootstrap +- plain tests are not forced to inherit app-like `.env` values from command-level `.env` parsing or broad parent runtime env forwarding + +This avoids command-level leakage across mixed suites containing raw PHPUnit tests, framework tests, and Testbench application tests. + +Direct `vendor/bin/phpunit` does not apply Testbench YAML `env:` because there is no Testbench CLI app. That matches Orchestra's CLI-only behavior for the YAML `env` key. + +If a key exists in both package/workbench `.env` and Testbench YAML `env:`, the explicit YAML forward is process env for the child and therefore wins over the runtime clone `.env` file. This matches dotenv's immutable precedence: shell/CI env and explicit process env are stronger than files loaded later by the child application. + +The final package-test env contract is: + +| Consumer | Gets env from | +| --- | --- | +| Raw PHPUnit, plain tests | shell/CI env, PHPUnit XML, and suite bootstrap | +| Raw PHPUnit, Testbench tests | shell/CI env, PHPUnit XML, suite bootstrap, and a real `.env` already present in the skeleton source copied by `copyDirectory()`; raw mode never gets package/workbench `.env` or skeleton `.env.example` through Bootstrapper | +| `package:test`, Testbench tests | shell/CI env, PHPUnit XML, suite bootstrap, command-owned package-test vars, explicit Testbench YAML `env:`, and package/workbench `.env` or skeleton `.env.example` through the `TESTBENCH_PACKAGE_TESTER`-gated runtime clone copy | +| `package:test`, plain tests | shell/CI env, PHPUnit XML, suite bootstrap, command-owned package-test vars, and explicit Testbench YAML `env:`; plain tests do not get app/skeleton env files | +| `artisan test` in apps | unchanged Laravel-style app semantics; `clearEnv()` now actually removes parent app dotenv values before spawning children, and `.env.testing` is loaded by the child application bootstrap | + +The old components wrapper never set `TESTBENCH_PACKAGE_TESTER`, so the package-tester branch of upstream mode-keyed Testbench assertions was not exercised in the components repo. A scoped `test:testbench` script now makes that contract live without forcing the entire framework suite into package-tester semantics. + +Defining `TESTBENCH_CORE` in the Testbench binary also makes root package provider discovery active in Testbench CLI children, which is the intended Orchestra-compatible behavior for real packages. In the components repo, the root `extra.hypervel.providers` list is a monorepo development aggregate, not the package under test for Testbench's own contract suite. A remote Testbench CLI child can create the runtime clone vendor symlink, run package discovery, and rebuild the shared clone's `bootstrap/cache/packages.php` while `TESTBENCH_CORE` is defined. That persists the components root aggregate providers into the clone. Later WithWorkbench test apps read the same file and, unless the root package is filtered at read time, can boot providers such as Fortify. Fortify then registers the named `login` route, which breaks the upstream Slim skeleton contract that the route is absent until the test defines it. The fix is to add `dont-discover: ['hypervel/components']` to Testbench's own `src/testbench/testbench.yaml`, keeping real package discovery behavior unchanged while preventing the components root aggregate from acting as the Testbench package under test. + +The scoped package-mode run also exposed a route-cache lifecycle bug. `defineCacheRoutes()` is intentionally supported before `parent::setUp()`, but the old worker-specific route cache path was configured only during application creation. In pre-`setUp()` cached-route tests, the parent and remote `route:cache` child could fall back to the shared `bootstrap/cache/routes-v7.php` path before the worker-specific `APP_ROUTES_CACHE` value existed. This caused isolated parallel cached-route tests to fail and could let one worker's cached routes make another worker skip route setup. The fix is to make cache path setup idempotent and call it both from application creation and from `defineCacheRoutes()` itself, deriving the ParaTest worker token directly from the runtime arrays because this path must work before the container exists. + +The scoped package-mode run also exposed shared route-file leakage. `defineCacheRoutes()` wrote route files as `routes/testbench-{time}.php`, and `SyncTestbenchCachedRoutes` intentionally loads every `routes/testbench-*.php` file in the worker clone so remote child processes can see stashed route definitions. The one-second filename resolution made collisions possible, and the old teardown deleted every matching route file rather than only the files owned by the current test instance. The fix is to write collision-proof route file names using the worker token, process ID, and monotonic timestamp; track the exact files written by the test instance; and delete only those owned files plus the current app's route cache file during teardown. + +The scoped run also exposed a Testbench runtime clone cleanup race. Remote Testbench children with the same ParaTest token can bootstrap concurrently and both try to purge the same stale runtime copy left behind by a killed serve child. The shared `Filesystem::deleteDirectory()` behavior stays Laravel-identical; Testbench runtime-copy cleanup handles this local temp-directory race by retrying once and then accepting only the postcondition that the runtime directory is gone. If the directory still exists, the filesystem exception is rethrown. + +The raw ParaTest full suite also exposed an AOP proxy source-map bug. `GenerateProxies` writes generated proxy paths back into Composer's runtime class map. Later app bootstraps then harvested that mutated map as if it still contained source paths. While the generated proxy file still existed, the bug was masked because `ProxyManager::isModified()` compared the proxy file against itself and skipped regeneration. After `cache:clear` / `optimize:clear` deleted `storage/framework/aop`, or after `package:purge-skeleton` purged the same directory from the Testbench runtime clone, a later app bootstrap tried to read the deleted proxy file as source and failed. The fix is for `GenerateProxies` to keep a worker-lifetime source class map, merge Composer's current class map into it on each proxy-generation boot while filtering generated `.proxy.php` paths, and pass explicit source file paths into `Ast` during proxy generation. Exact-rule `findFile()` resolutions must also reject generated proxy paths, because test-only `flushState()` can clear the captured source map while Composer's loader still contains a proxy entry for a previously proxied PSR-4 class. `ProxyManager` must track which source path generated each existing proxy file, because a later class-map override can point a class to a different source file whose mtime is older than the stale proxy. In that case, source-path drift must force regeneration even when the mtime check would otherwise skip it. Do not solve this by restoring Composer's loader from test cleanup; proxy generation should be self-sufficient and should not depend on per-test cleanup mutating the autoloader back into shape. + +An earlier version of this plan said the full components suite should use `package:test`. That was wrong. Full-suite diagnostics proved the user's original mixed-suite concern was correct: + +- `php src/testbench/bin/testbench package:test --parallel --without-tty` produced `119` errors and `37` failures across `21236` tests. +- Temporarily removing only `SESSION_DRIVER=cookie` and `CACHE_STORE=database` from the Testbench skeleton `.env.example` reduced that to `4` errors and `29` failures. +- The removed failures were the database-cache, cache-lock, cookie-session, and response-500 clusters. +- The remaining failures were still package-mode collisions: `APP_NAME=Testbench` from `src/testbench/testbench.yaml`, `APP_DEBUG=true` from the skeleton env, and mode-keyed Testbench defaults that specifically expect package mode. + +A framework suite that tests env loading, config loading, exception rendering, and other bootstrap behavior cannot run inside package-tester semantics without fighting the mode it is trying to test. The components full-suite gate should therefore use raw ParaTest, matching the way Laravel's framework suite and Orchestra's own raw suite are run. Package-mode behavior remains covered by a scoped Testbench contract run. + +Hypervel's skeleton config files carry only intentional differences from the framework base configuration. `LoadConfiguration` merges the framework config into the application config by default, and `cache.stores` is deep-merged. Do not copy full Orchestra-style skeleton config files into Hypervel; trace Hypervel's config merge first and keep only the overrides that differ from the framework defaults. + +## Decisions + +### Delete the components Redis preflight wrapper + +Remove `bin/paratest`. + +Change the components full-suite script to use raw ParaTest: + +```json +{ + "scripts": { + "test:parallel": "paratest" + } +} +``` + +Add the parallel-testing flag to `phpunit.xml.dist`: + +```xml + +``` + +This is safe for sequential `phpunit` runs because `ParallelTesting::inParallel()` requires both `HYPERVEL_PARALLEL_TESTING` and a ParaTest `TEST_TOKEN`. + +Add a scoped package-mode contract script: + +```json +{ + "scripts": { + "test:testbench": "php src/testbench/bin/testbench package:test --parallel tests/Testbench" + } +} +``` + +`test:parallel` verifies the full components framework suite with raw framework semantics. `test:testbench` verifies Testbench package-mode behavior with real `package:test` semantics. + +Because ParaTest defaults to the machine CPU count, users must ensure the Redis worker range can cover the selected process count when Redis tests run. The old wrapper capped processes from Redis capacity; the new model fails clearly when the configured range is too small. Document that users can pass `--processes` / `-p` to choose a process count that fits their Redis test range. + +### Fix the Testbench binary CLI marker + +Add the Testbench CLI marker to `src/testbench/bin/testbench`: + +```php +define('TESTBENCH_CORE', true); +``` + +Place it after the autoloader is loaded and before Testbench functions such as `is_testbench_cli()` can be used through the command path. This matches Orchestra's binary and makes `is_testbench_cli()` true when the Testbench CLI is actually running. + +Verify: + +- `php src/testbench/bin/testbench list` shows package commands, including `package:test` +- `PackageManifest::providersFromTestbench()` can merge the root package `extra.hypervel` metadata while running through the Testbench CLI +- the components root metadata merge does not produce duplicate or surprising providers during `test:testbench` + +### Fix Testbench subprocess env ownership + +The Testbench package command should not forward the temporary CLI application's full runtime env to PHPUnit or ParaTest children. Only command-owned values and declared Testbench YAML `env:` values belong in the explicit env array: + +```php +return (new Collection($this->configurationEnvironmentVariables()))->merge(parent::baseEnvironmentVariables())->merge([ + 'TESTBENCH_PACKAGE_TESTER' => '(true)', + 'TESTBENCH_WORKING_PATH' => package_path(), +]); +``` + +Package/workbench env files should reach Testbench application tests through the child runtime clone, not through parent process leakage. Extract the shared env-file resolution policy to a small internal Testbench foundation class. It should have two explicit resolution methods so callers choose whether they need only package/workbench env files or the full Testbench skeleton fallback behavior: + +```php +namespace Hypervel\Testbench\Foundation; + +use Hypervel\Filesystem\Filesystem; + +class EnvironmentFile +{ + public function __construct( + protected Filesystem $filesystem + ) { + } + + public function package(string $workingPath, string $filename = '.env'): ?string + { + // Resolve only package/workbench env candidates. + } + + public function packageOrSkeletonFallback(string $workingPath, string $appBasePath, string $filename = '.env'): ?string + { + return $this->package($workingPath, $filename) + ?? $this->skeletonFallback($appBasePath); + } +} +``` + +The implementation should use the existing candidate order from `CopyTestbenchFiles::copyTestbenchDotEnvFile()`: + +```text + +.example +.dist +.env +.env.example +.env.dist +``` + +`TESTBENCH_ENVIRONMENT_FILENAME` must still be respected. `CopyTestbenchFiles::copyTestbenchDotEnvFile()` should stay as a thin delegate for future Orchestra merges, but source resolution should come from the shared class. `Bootstrapper::createRuntimeCopy()` should copy the resolved package/workbench env file or skeleton `.env.example` fallback into `$runtimePath/.env` after copying the skeleton directory only when `TESTBENCH_PACKAGE_TESTER` is present. Remote child processes reuse the already-created runtime copy. + +The skeleton fallback is preserved in the child runtime app so existing Testbench expectations continue to hold. The bug was not the fallback itself; it was loading that fallback into the parent CLI app and then leaking it to child processes before PHPUnit could load the package suite's own env. + +Fix `TestCommandBase::clearEnv()` to delete parent app env keys from all env adapters: + +```php +$variables = self::getEnvironmentVariables($path, $this->hypervel->environmentFile()); + +Env::getRepository(); +Env::deleteMany($variables); +``` + +Add a short source comment explaining why `deleteMany()` is used instead of the repository's `clear()` method. The reason is the immutable dotenv writer: after `Env::enablePutenv()` rebuilds the repository, the writer did not load the parent keys and refuses to clear them as externally defined. + +### Keep Redis isolation inside `InteractsWithRedis` + +Redis worker selection should happen when a test uses `InteractsWithRedis`, after the application/test runtime exists. + +This preserves the current useful behavior: + +- non-Redis tests do not connect to Redis +- Redis tests skip when the default fallback Redis service is unavailable +- explicit Redis misconfiguration still fails +- `flushdb()` is safe because each worker owns a logical database +- low-level tests can use raw Redis commands without prefix caveats + +### Non-parallel `InteractsWithRedis` tests use `REDIS_DB` + +Sequential tests using `InteractsWithRedis` should not unexpectedly reserve or use a second database. + +With: + +```env +REDIS_DB=3 +``` + +`phpunit` or `php artisan test` without `--parallel` uses: + +```text +primary Redis database = 3 +``` + +`REDIS_TEST_DB_MIN` and `REDIS_TEST_DB_MAX` are parallel-worker allocation settings. They do not change sequential primary database selection. + +### Parallel `InteractsWithRedis` tests use a configured worker range + +Parallel Redis worker databases are allocated from: + +```env +REDIS_TEST_DB_MIN=... +REDIS_TEST_DB_MAX=... +``` + +Defaults: + +```text +REDIS_TEST_DB_MIN = REDIS_DB, default 0 +REDIS_TEST_DB_MAX = 15 +``` + +Without a secondary database: + +```text +REDIS_DB=1 + +worker 1 -> database 1 +worker 2 -> database 2 +... +worker 15 -> database 15 +``` + +With an explicit range: + +```env +REDIS_TEST_DB_MIN=4 +REDIS_TEST_DB_MAX=8 +``` + +```text +worker 1 -> database 4 +worker 2 -> database 5 +worker 3 -> database 6 +worker 4 -> database 7 +worker 5 -> database 8 +``` + +If a worker cannot be assigned a database from the configured range, the test fails with a clear exception. It should not skip. A range that cannot support the requested process count is a test environment error. + +### Secondary Redis database is explicit + +`getSecondaryRedisDb()` should require: + +```env +REDIS_TEST_SECONDARY_DB=... +``` + +There is no fallback to `REDIS_TEST_DB_MAX`. + +That keeps the behavior honest: + +- normal apps and packages use one Redis database per test worker +- advanced tests opt into a second database only when needed +- framework tests that assert `select()` behavior keep their coverage + +If `REDIS_TEST_SECONDARY_DB` is configured, it is reserved. Worker allocation skips it when it falls inside the worker range. + +Example: + +```env +REDIS_TEST_DB_MIN=1 +REDIS_TEST_DB_MAX=15 +REDIS_TEST_SECONDARY_DB=15 +``` + +```text +worker 1 -> database 1 +worker 2 -> database 2 +... +worker 14 -> database 14 +database 15 -> secondary database +worker 15 -> fail, because no worker database is available +``` + +If the secondary database is outside the worker range, no worker skip is needed: + +```env +REDIS_TEST_DB_MIN=1 +REDIS_TEST_DB_MAX=14 +REDIS_TEST_SECONDARY_DB=15 +``` + +```text +worker databases = 1..14 +secondary database = 15 +``` + +### Do not probe Redis database capacity + +Do not call `CONFIG GET databases`. + +Managed Redis services can restrict that command, and a parent process cannot know whether Redis will actually be used by the test suite. The configured range is the contract. If the range is too large for the test Redis server, the actual Redis operation fails and points at the test environment configuration. + +### Components env files configure the framework test range + +The components repo runs low-level Redis tests that need a secondary database. Update both `.env` and `.env.example`: + +```env +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD=password +REDIS_DB=1 +REDIS_TEST_DB_MIN=1 +REDIS_TEST_DB_MAX=14 +REDIS_TEST_SECONDARY_DB=15 +``` + +The example should keep the same commented style as the existing Redis entries. + +### Documentation belongs in Boost testing docs + +`src/boost/docs/testing.md` already has a `Running Tests in Parallel` section with database, hook, and token subsections. Add a Redis subsection there, near `Parallel Testing and Databases`. + +The docs update must match the current Boost documentation style and voice: + +- same heading depth and anchor format +- Laravel-like user-facing prose +- concise explanation +- no implementation chatter +- no agent-oriented wording + +`src/boost/docs/testbench.md` should get a short note that package tests use the same parallel Redis testing behavior as application tests. + +## Implementation Plan + +### 1. Replace the components parallel scripts + +Update `composer.json`: + +```json +"test:parallel": "paratest", +"test:testbench": "php src/testbench/bin/testbench package:test --parallel tests/Testbench" +``` + +Delete `bin/paratest`. + +Update `phpunit.xml.dist`: + +```xml + +``` + +Place it in the existing `` block with the other suite-level settings. + +Update the app skeleton `contrib/hypervel/hypervel/phpunit.xml` the same way. App tests normally use `php artisan test --parallel`, which already injects the flag, but the skeleton should also make raw `vendor/bin/paratest` work correctly. Plain `phpunit` still runs normally because ParaTest is the process that supplies `TEST_TOKEN`. + +Wire `test:testbench` into `check` and `fix` so the normal quality gates cover both the framework suite and the package-mode Testbench contract. + +Update `src/testbench/bin/testbench` to define `TESTBENCH_CORE` before the command is built: + +```php +define('TESTBENCH_CORE', true); +``` + +Update `AGENTS.md` so it says `composer test:parallel` uses raw ParaTest for the full components suite and `composer test:testbench` uses the Testbench package test command for package-mode contracts. + +Search active source/docs for stale references: + +```shell +grep -R -n "bin/paratest\\|custom wrapper\\|preflight\\|pre-flight" AGENTS.md composer.json src tests --include='*.md' --include='*.php' --include='composer.json' +``` + +Historical plan documents may still describe their own historical work. Active docs, source comments, and command help must not reference the deleted wrapper as current behavior. + +### 2. Fix Testbench env ownership + +Create `src/testbench/src/Foundation/EnvironmentFile.php`. + +The class should centralize the existing Testbench env-file resolution policy and expose explicit methods for the two real callers: + +```php +sourcePath($workingPath); + $environmentPath = $this->filesystem->isDirectory(join_paths($sourcePath, 'workbench')) + ? join_paths($sourcePath, 'workbench') + : $sourcePath; + + return $this->firstExisting($environmentPath, $this->candidateNames($filename)); + } + + /** + * Resolve the package or workbench environment file, falling back to the skeleton example. + */ + public function packageOrSkeletonFallback(string $workingPath, string $appBasePath, string $filename = '.env'): ?string + { + return $this->package($workingPath, $filename) + ?? $this->skeletonFallback($appBasePath); + } + + /** + * Resolve the source path for testbench config and workbench fixtures. + */ + public function sourcePath(string $workingPath): string + { + foreach (['testbench.yaml', 'testbench.yaml.example', 'testbench.yaml.dist'] as $configurationFile) { + if ($this->filesystem->isFile(join_paths($workingPath, $configurationFile))) { + return $workingPath; + } + } + + if ($this->filesystem->isDirectory(join_paths($workingPath, 'workbench'))) { + return $workingPath; + } + + return testbench_path(); + } + + /** + * Get the ordered environment file candidate names. + * + * @return array + */ + protected function candidateNames(string $filename): array + { + return array_values(array_unique([ + $filename, + "{$filename}.example", + "{$filename}.dist", + '.env', + '.env.example', + '.env.dist', + ])); + } +} +``` + +The final implementation should also include the private helpers needed by the snippet, such as `firstExisting()` and `skeletonFallback()`, with method docblocks matching the package style. Import `join_paths()` and `testbench_path()` from the Testbench functions namespace. + +Update `src/testbench/src/Foundation/Console/Concerns/CopyTestbenchFiles.php`: + +- keep `copyTestbenchDotEnvFile()` as a thin delegate +- resolve the configuration source path through `EnvironmentFile::sourcePath()` +- resolve the env source through `EnvironmentFile::packageOrSkeletonFallback()` +- keep the existing backup and terminating cleanup behavior unchanged +- remove local duplicated candidate-order logic from the trait + +Update `src/testbench/src/Bootstrapper.php`: + +- pass the resolved Testbench working path through `resolveRuntimeBasePath()` and `createRuntimeCopy()` +- after copying the skeleton directory to `$runtimePath`, copy package-test env files only when `Env::has('TESTBENCH_PACKAGE_TESTER')` +- when package-tester mode is active, resolve `EnvironmentFile::packageOrSkeletonFallback($workingPath, $runtimePath, $environmentFilename)` and copy the resolved env file to `join_paths($runtimePath, '.env')` +- when package-tester mode is not active, do not copy package/workbench `.env` or skeleton `.env.example`; raw PHPUnit Testbench mode must preserve Orchestra's behavior and only load a real `.env` that already exists in the skeleton source copied by `copyDirectory()` +- do not copy anything in the remote-process branch, because that branch reuses the runtime copy created by the parent process + +The environment filename should come from `TESTBENCH_ENVIRONMENT_FILENAME` when set, otherwise `.env`. + +Update `src/testing/src/Console/TestCommandBase.php`: + +```php +$variables = self::getEnvironmentVariables($path, $this->hypervel->environmentFile()); + +Env::getRepository(); +Env::deleteMany($variables); +``` + +Add a concise source comment above the `Env::getRepository()` / `Env::deleteMany()` calls explaining that the immutable repository writer cannot clear keys it did not load, so test commands must delete directly from the adapters after rebuilding the adapter list. + +Update `src/testbench/src/Foundation/Console/TestCommand.php` so `baseEnvironmentVariables()` no longer merges `defined_environment_variables()`: + +```php +return (new Collection($this->configurationEnvironmentVariables()))->merge(parent::baseEnvironmentVariables())->merge([ + 'TESTBENCH_PACKAGE_TESTER' => '(true)', + 'TESTBENCH_WORKING_PATH' => package_path(), +]); +``` + +The command should not import `defined_environment_variables()` after this change. + +Add an internal helper that forwards only declared Testbench YAML `env:` values. Use the same dotenv `Parser` and `StringStore` semantics as `LoadEnvironmentVariablesFromArray`, and merge these values before parent command vars and command-owned package-test vars so `APP_ENV`, `TESTBENCH_PACKAGE_TESTER`, and `TESTBENCH_WORKING_PATH` keep winning. + +Suggested shape: + +```php +use Dotenv\Parser\Entry; +use Dotenv\Parser\Parser; +use Dotenv\Store\StringStore; + +/** + * Get configured Testbench environment variables. + * + * @return array + */ +protected function configurationEnvironmentVariables(): array +{ + $environmentVariables = Bootstrapper::getConfiguration()['env'] ?? []; + + if (! is_array($environmentVariables) || $environmentVariables === []) { + return []; + } + + $store = new StringStore(implode(PHP_EOL, $environmentVariables)); + $parser = new Parser; + $variables = []; + + foreach ($parser->parse($store->read()) as $entry) { + if ($entry->getValue()->isDefined()) { + $variables[$entry->getName()] = $entry->getValue()->get()->getChars(); + } + } + + return $variables; +} +``` + +This preserves the intended package-test YAML env channel without forwarding arbitrary parent CLI runtime env or skeleton `.env` values. + +### 3. Refactor `InteractsWithRedis` + +Remove wrapper-era state: + +```php +private static bool $noRedisDbAvailable = false; +private function isRedisDbAvailable(int $db): bool +``` + +The range is now configured by env; Redis capacity probing is gone. + +Keep the existing default-Redis skip behavior: + +```php +if ($host === '127.0.0.1' && $port === 6379 && env('REDIS_HOST') === null) { + static::$connectionFailedOnceWithDefaultsSkip = true; + $this->markTestSkipped(...); +} +``` + +Explicit Redis configuration failures should continue to throw. + +Create a small `Hypervel\Foundation\Testing\RedisTestDatabases` helper for the pure allocation math. The trait still owns test-case lifecycle, Redis flushing, config mutation, and resolver-aware token lookup; the helper only maps env values and tokens to database numbers. This keeps executable bootstraps such as Horizon's `worker.php` from copying trait internals. + +```php +use RuntimeException; + +class RedisTestDatabases +{ + public static function baseDatabase(): int; + + public static function minimumDatabase(): int; + + public static function maximumDatabase(): int; + + public static function configuredSecondaryDatabase(): ?int; + + public static function primaryDatabase(string|false $token): int; + + public static function databaseForToken(string $token): int; + + public static function secondaryDatabase(string|false $token): int; + + /** @return array */ + public static function workerDatabases(): array; +} +``` + +Use full variable names in implementation (`$database`, `$databases`, `$workerIndex`, `$token`) rather than abbreviations. + +Parallel database allocation should be deterministic and should skip the configured secondary database: + +```php +protected function getParallelRedisDb(): int +{ + return RedisTestDatabases::primaryDatabase($this->parallelTestingToken()); +} + +protected function redisDatabaseForParallelToken(string $token): int +{ + return RedisTestDatabases::databaseForToken($token); +} +``` + +Resolve the token through the framework API: + +```php +protected function parallelTestingToken(): string|false +{ + return $this->app->make(ParallelTesting::class)->token(); +} +``` + +This preserves support for `ParallelTesting::resolveTokenUsing()` while keeping standard ParaTest behavior unchanged. + +Token parsing should fail clearly if a malformed token is supplied: + +```php +public static function workerIndex(string $token): int +{ + if (! ctype_digit($token) || (int) $token < 1) { + throw new RuntimeException('TEST_TOKEN must be a positive integer for Redis parallel testing.'); + } + + return (int) $token - 1; +} +``` + +Env integer parsing should also fail clearly for invalid Redis database settings: + +```php +public static function integerEnvironment(string $key, int $default): int +{ + $value = env($key); + + if ($value === null) { + return $default; + } + + return static::integerEnvironmentValue($key, $value); +} + +public static function integerEnvironmentValue(string $key, mixed $value): int +{ + if (is_int($value)) { + if ($value < 0) { + throw new RuntimeException("{$key} must be a non-negative integer."); + } + + return $value; + } + + if (is_string($value) && ctype_digit($value)) { + return (int) $value; + } + + throw new RuntimeException("{$key} must be a non-negative integer."); +} +``` + +The implementation should also reject negative database numbers. If the final helper accepts only `ctype_digit()` strings and non-negative integers, negative strings are already rejected. + +Update `configureParallelRedisDb()`: + +```php +private function configureParallelRedisDb(): void +{ + if ($this->parallelTestingToken() === false) { + return; + } + + $database = $this->getParallelRedisDb(); + + $this->app->make('config')->set('database.redis.default.database', $database); +} +``` + +Keep `createRedisConnectionWithPrefix()` using `getParallelRedisDb()` so named Redis connections also inherit the worker database. + +Update `tests/Integration/Horizon/worker.php` so its subprocess bootstrap uses the same allocator: + +```php +$token = getenv('TEST_TOKEN'); + +if (is_string($token)) { + $config->set('database.redis.default.database', RedisTestDatabases::databaseForToken($token)); +} +``` + +Update the trait docblock to describe the new env vars: + +```text +REDIS_DB +REDIS_TEST_DB_MIN +REDIS_TEST_DB_MAX +REDIS_TEST_SECONDARY_DB +``` + +Remove comments that claim the base database is always reserved as the secondary database. + +The updated docblock should keep the existing guidance that the secondary database is shared by tests that explicitly request it. Tests should use unique keys and delete those keys; they should not call `flushdb()` on the secondary database. + +### 4. Update framework Redis tests + +Update helper tests in `tests/Foundation/Testing/Concerns/InteractsWithRedisParallelTest.php`. + +The existing sequential tests for implicit `base + 1` secondary behavior should be replaced. New coverage should include: + +- non-parallel `getParallelRedisDb()` returns `REDIS_DB` +- parallel worker token maps to the configured min-based database +- `REDIS_TEST_DB_MIN` defaults to `REDIS_DB` +- `REDIS_TEST_DB_MAX` defaults to `15` +- overflow fails instead of skipping +- `REDIS_TEST_SECONDARY_DB` is required by `getSecondaryRedisDb()` +- configured secondary database is returned +- configured secondary database is skipped during worker allocation +- secondary database matching the current primary database fails +- invalid min/max range fails +- invalid database env values fail +- invalid `TEST_TOKEN` fails + +These tests should avoid requiring a live Redis service. They should exercise helper methods through a local harness, like the current file does. + +Do not keep the current skip pattern for sequential assertions. The test file must run deterministically in both normal PHPUnit and ParaTest workers. Use controlled state per test: + +- control token-dependent cases with `ParallelTesting::resolveTokenUsing(fn () => '3')` +- reset the token resolver with `ParallelTesting::resolveTokenUsing(null)` in `tearDown()` +- set `REDIS_DB`, `REDIS_TEST_DB_MIN`, `REDIS_TEST_DB_MAX`, and `REDIS_TEST_SECONDARY_DB` through `$_SERVER` and/or the Env repository +- capture original `$_SERVER`, `$_ENV`, and process env values before mutation +- restore all changed values in `tearDown()` +- flush the Env repository after changes when needed so `env()` reads the intended values + +Also add coverage proving a custom `ParallelTesting::resolveTokenUsing()` value is honored by `InteractsWithRedis`. + +Update integration tests that need the secondary database only if required by the new helper contract. Since components `.env` will set `REDIS_TEST_SECONDARY_DB`, the actual integration tests should continue to call `getSecondaryRedisDb()` directly. + +Update `RedisProxyIntegrationTest::testPipelineCallbackAndPipeline` so the `pipeline_select_junk_*` key written to the secondary database is deleted. The old wrapper flushed the shared secondary database before each run; that flush is going away, so secondary database tests must clean up their own keys. + +Run the changed test file immediately: + +```shell +./vendor/bin/phpunit tests/Foundation/Testing/Concerns/InteractsWithRedisParallelTest.php +``` + +Run relevant Redis integration tests after the helper changes: + +```shell +./vendor/bin/phpunit tests/Integration/Redis/RedisProxyIntegrationTest.php +./vendor/bin/phpunit tests/Integration/Redis/RedisConnectionIntegrationTest.php +``` + +### 5. Update command and Testbench env tests + +`tests/Testbench/Foundation/Console/TestCommandTest.php` already verifies package command arguments and env variables. + +Verify the existing coverage still proves the package command builds ParaTest arguments with: + +```text +--runner=Hypervel\Testbench\Features\ParallelRunner +``` + +and still forwards normal PHPUnit filters. Existing coverage already checks this, so do not add duplicate assertions unless a real gap appears during implementation. + +Add or adjust coverage for the components script indirectly by checking `composer.json` or through a narrow command test if there is an existing pattern for script assertions. Do not add brittle shell assertions if a simple source check is enough. + +Add focused coverage for the Testbench binary marker if there is an appropriate command/testbench test location. At minimum, verify manually during implementation that `php src/testbench/bin/testbench list` shows `package:test`. + +Add package command env ownership coverage: + +- parent runtime env keys such as `APP_NAME` and `REDIS_PASSWORD` are not included in package command env variables +- `TESTBENCH_PACKAGE_TESTER` and `TESTBENCH_WORKING_PATH` are still included +- `TESTBENCH_APP_BASE_PATH` is not included; `APP_BASE_PATH` remains the explicit user override +- declared Testbench YAML `env:` values are included in package command env variables +- command-owned package-test vars override colliding YAML env values + +Add shared command `clearEnv()` coverage in `tests/Testing/Console/TestCommandTest.php`: + +- create an isolated env file that names a few variables +- set those variables in `$_SERVER`, `$_ENV`, and with `putenv()` +- call the command harness method that exposes `clearEnv()` +- assert every named key is absent from `$_SERVER`, `$_ENV`, and `getenv()` + +Add runtime clone env-file coverage in `tests/Testbench/BootstrapperTest.php`: + +- in package-tester mode, a package/workbench env file is copied to the runtime clone as `.env` +- in package-tester mode, the skeleton `.env.example` is copied to the runtime clone as `.env` when no package/workbench env file resolves +- outside package-tester mode, no package/workbench env file and no skeleton `.env.example` fallback is copied to the runtime clone +- the test restores Bootstrapper static properties, `TESTBENCH_PACKAGE_TESTER` in `$_SERVER`, `$_ENV`, and process env, and removes temporary directories in `finally` + +### 6. Complete package-test skeleton config parity + +The real `package:test` runner sets `TESTBENCH_PACKAGE_TESTER`, so upstream mode-keyed Testbench assertions expect the skeleton `.env.example` package defaults to be active. Update `src/testbench/hypervel/.env.example`: + +```env +SESSION_DRIVER=cookie +CACHE_STORE=database +``` + +Keep `src/testbench/hypervel/config/cache.php` minimal. The existing file is already the correct set of Testbench-specific differences: + +- `default` falls back to `array` in raw mode, while package-test mode reads `CACHE_STORE=database` +- the skeleton Redis cache store uses the default Redis connection because the skeleton does not define a separate `cache` Redis connection +- the cache prefix is deterministic for tests + +The framework base config supplies the `database` cache store through the normal config merge. + +Update `src/testbench/hypervel/config/session.php` to keep only intentional differences: + +```php +return [ + 'driver' => env('SESSION_DRIVER', 'array'), + 'lottery' => [0, 2], + 'cookie' => 'hypervel_session', +]; +``` + +Do not duplicate the rest of the framework session config. Omitting `store` inherits the base `env('SESSION_STORE')` value. The zero lottery and pinned cookie name are intentional test skeleton differences. + +Run changed command test files immediately: + +```shell +./vendor/bin/phpunit tests/Testbench/Foundation/Console/TestCommandTest.php +./vendor/bin/phpunit tests/Testing/Console/TestCommandTest.php +./vendor/bin/phpunit tests/Testbench/BootstrapperTest.php +``` + +Run changed package manifest tests immediately: + +```shell +php src/testbench/bin/testbench package:test --without-tty tests/Testbench/Foundation/PackageManifestTest.php +``` + +Run changed Testbench route-file tests immediately: + +```shell +php src/testbench/bin/testbench package:test --without-tty tests/Testbench/Concerns/DefineCacheRoutesTest.php +php src/testbench/bin/testbench package:test --without-tty tests/Testbench/Concerns/DefineCacheRoutesBeforeSetUpTest.php +php src/testbench/bin/testbench package:test --without-tty tests/Testbench/Integrations/CacheRouteTest.php +php src/testbench/bin/testbench package:test --without-tty tests/Testbench/Integrations/InlineCacheRouteTest.php +php src/testbench/bin/testbench package:test --without-tty tests/Testbench/Integrations/StashRouteTest.php +php src/testbench/bin/testbench package:test --parallel --processes=6 --without-tty tests/Testbench/Integrations/SlimSkeletonApplicationTest.php +php src/testbench/bin/testbench package:test --parallel --processes=6 --without-tty tests/Testbench/CommanderServeTest.php +``` + +### 7. Update env files + +Update `.env`: + +```env +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD=password +REDIS_DB=1 +REDIS_TEST_DB_MIN=1 +REDIS_TEST_DB_MAX=14 +REDIS_TEST_SECONDARY_DB=15 +``` + +Update `.env.example` in the same Redis section and keep the example variables commented, matching the existing file style: + +```env +# REDIS_HOST=127.0.0.1 +# REDIS_PORT=6379 +# REDIS_PASSWORD=password +# REDIS_DB=1 +# REDIS_TEST_DB_MIN=1 +# REDIS_TEST_DB_MAX=14 +# REDIS_TEST_SECONDARY_DB=15 +``` + +Update `.github/workflows/redis.yml` in both Redis 8 and Valkey 9 jobs. They run `vendor/bin/phpunit tests/Integration/Redis` sequentially with only `REDIS_HOST` / `REDIS_PORT`; after `getSecondaryRedisDb()` becomes explicit, those jobs need: + +```yaml +REDIS_TEST_SECONDARY_DB: 1 +``` + +Sequential primary defaults to Redis database `0` in those jobs, so database `1` is a safe secondary database. + +### 8. Update documentation + +Update `src/boost/docs/testing.md`. + +Add a subsection after `Parallel Testing and Databases`: + +```md + +#### Parallel Testing and Redis +``` + +Document: + +- tests using `InteractsWithRedis` use the normal configured Redis database when not running in parallel +- parallel tests using `InteractsWithRedis` use `REDIS_TEST_DB_MIN` and `REDIS_TEST_DB_MAX` +- defaults are `REDIS_TEST_DB_MIN=REDIS_DB` and `REDIS_TEST_DB_MAX=15` +- Hypervel fails when a worker cannot be assigned a Redis database +- ParaTest uses the machine CPU count by default, so the Redis worker range must cover the process count or the user should pass `--processes` +- `REDIS_TEST_SECONDARY_DB` is only needed for tests that call the secondary database helper +- when configured, the secondary database is reserved and skipped by worker allocation + +Use the same language style as the surrounding docs. Do not mention internal trait names unless needed for user clarity. + +Update the callback examples in the existing parallel testing docs so the token parameter type is `string`, matching `ParallelTesting::token(): string|false` and the already-committed `ParallelRunner` token normalization. + +Update `src/boost/docs/testbench.md` with a short package-test note: + +```md +Package tests use the same parallel database, cache, and Redis isolation behavior as application tests. +``` + +Keep the wording consistent with the rest of the page. + +Update `src/testbench/README.md`. It is currently a stub, so add: + +```md +Ported from: https://github.com/orchestral/testbench-core +``` + +Also add a `Differences From Orchestra Testbench` section explaining that Hypervel does not forward the parent Testbench CLI application's full runtime env to `package:test` subprocesses. Package/workbench env files are loaded from the child runtime clone; shell/CI env, PHPUnit XML values, and Testbench YAML `env:` values still reach package-test child processes through their normal channels. + +### 9. Update AGENTS.md + +Change the `composer test:parallel` guidance to reflect the new command path: + +```md +**Always use `composer test:parallel`** to run the full components suite. This runs raw ParaTest with Hypervel's parallel testing flag supplied by `phpunit.xml.dist`. +``` + +Also document `composer test:testbench` as the scoped package-mode Testbench contract run. Do not mention Redis preflight or the deleted wrapper as current behavior. Do not say direct `vendor/bin/paratest` bypasses setup; after this change direct ParaTest and `composer test:parallel` use the same runtime setup. + +Also add a short note that the default ParaTest process count comes from CPU count, and Redis integration runs need `REDIS_TEST_DB_MIN` / `REDIS_TEST_DB_MAX` to cover the chosen worker count. + +## Testing Plan + +Run focused tests after each changed test file: + +```shell +./vendor/bin/phpunit tests/Foundation/Testing/Concerns/InteractsWithRedisParallelTest.php +./vendor/bin/phpunit tests/Integration/Redis/RedisProxyIntegrationTest.php +./vendor/bin/phpunit tests/Integration/Redis/RedisConnectionIntegrationTest.php +./vendor/bin/phpunit tests/Testbench/Foundation/Console/TestCommandTest.php +./vendor/bin/phpunit tests/Testing/Console/TestCommandTest.php +./vendor/bin/phpunit tests/Testbench/BootstrapperTest.php +php src/testbench/bin/testbench package:test --without-tty tests/Testbench/Foundation/PackageManifestTest.php +``` + +Run the focused Testbench route-file and auth checks: + +```shell +php src/testbench/bin/testbench package:test --without-tty tests/Testbench/Concerns/DefineCacheRoutesTest.php +php src/testbench/bin/testbench package:test --without-tty tests/Testbench/Concerns/DefineCacheRoutesBeforeSetUpTest.php +php src/testbench/bin/testbench package:test --without-tty tests/Testbench/Integrations/CacheRouteTest.php +php src/testbench/bin/testbench package:test --without-tty tests/Testbench/Integrations/InlineCacheRouteTest.php +php src/testbench/bin/testbench package:test --without-tty tests/Testbench/Integrations/StashRouteTest.php +php src/testbench/bin/testbench package:test --parallel --processes=6 --without-tty tests/Testbench/Integrations/SlimSkeletonApplicationTest.php +php src/testbench/bin/testbench package:test --parallel --processes=6 --without-tty tests/Testbench/CommanderServeTest.php +``` + +Run the Redis workflow-equivalent sequential command with the same env shape as CI: + +```shell +REDIS_HOST=127.0.0.1 REDIS_PORT=6379 REDIS_DB=0 REDIS_TEST_SECONDARY_DB=1 ./vendor/bin/phpunit tests/Integration/Redis +``` + +Run a targeted raw ParaTest smoke test: + +```shell +composer test:parallel -- --filter=InteractsWithRedisParallelTest +``` + +Run the focused Horizon subprocess check that proves spawned workers use the same Redis database as the parent test app: + +```shell +./vendor/bin/paratest --processes=2 tests/Integration/Horizon/Feature/SupervisorTest.php +``` + +Run the scoped package-mode contract script: + +```shell +composer test:testbench +``` + +Run a targeted package-command smoke test if package-mode Redis behavior needs a smaller reproduction: + +```shell +php src/testbench/bin/testbench package:test --parallel --filter=InteractsWithRedisParallelTest +``` + +Run the mode-keyed Testbench contract files directly with PHPUnit and through `package:test`: + +```shell +./vendor/bin/phpunit \ + tests/Testbench/Foundation/EnvTest.php \ + tests/Testbench/DefaultConfigurationTest.php \ + tests/Testbench/TestCaseTest.php \ + tests/Testbench/BootstrapperTest.php \ + tests/Testbench/Foundation/ApplicationTest.php \ + tests/Testbench/Attributes/UsesFrameworkConfigurationTest.php \ + tests/Testbench/Integrations/EnvironmentVariablesTest.php \ + tests/Testbench/Integrations/LoadUsingFrameworkConfigurationTest.php \ + tests/Testbench/Foundation/Console/InstallCommandTest.php + +php src/testbench/bin/testbench package:test --without-tty \ + tests/Testbench/Foundation/EnvTest.php \ + tests/Testbench/DefaultConfigurationTest.php \ + tests/Testbench/TestCaseTest.php \ + tests/Testbench/BootstrapperTest.php \ + tests/Testbench/Foundation/ApplicationTest.php \ + tests/Testbench/Attributes/UsesFrameworkConfigurationTest.php \ + tests/Testbench/Integrations/EnvironmentVariablesTest.php \ + tests/Testbench/Integrations/LoadUsingFrameworkConfigurationTest.php \ + tests/Testbench/Foundation/Console/InstallCommandTest.php +``` + +Run live Redis package-command checks that exercise inherited-env cleanup through Testbench: + +```shell +php src/testbench/bin/testbench package:test --without-tty --filter=testRedisLockCanBeAcquiredAndReleasedWithoutSerializationAndCompression +php src/testbench/bin/testbench package:test --parallel --without-tty --filter=PhpRedisCacheLockTest +``` + +Run the Testbench CLI list command to verify `TESTBENCH_CORE` behavior: + +```shell +php src/testbench/bin/testbench list +``` + +Run the full project check: + +```shell +composer fix +``` + +`composer fix` runs cs-fixer, PHPStan, raw `composer test:parallel`, and scoped `composer test:testbench`. + +After `composer fix` passes, run one direct command to prove raw ParaTest has the same setup as the Composer script: + +```shell +./vendor/bin/paratest --filter=InteractsWithRedisParallelTest +``` + +## Self-Review Checklist + +- `bin/paratest` is deleted. +- `composer.json` uses raw `paratest` for `test:parallel`. +- `composer.json` has a scoped `test:testbench` script for `tests/Testbench`. +- `composer.json` runs `test:testbench` from normal quality gates. +- `phpunit.xml.dist` sets `HYPERVEL_PARALLEL_TESTING` as a server variable. +- The app skeleton `phpunit.xml` sets `HYPERVEL_PARALLEL_TESTING` as a server variable. +- `src/testbench/bin/testbench` defines `TESTBENCH_CORE`. +- `src/testbench/testbench.yaml` ignores the `hypervel/components` root package so Testbench's own contract suite does not auto-discover the components root provider aggregate. +- Package manifest coverage proves specific package ignores filter root package metadata at read time while the cached file still contains the built root entry. +- `AGENTS.md` no longer says the suite runs through the custom wrapper. +- `AGENTS.md` no longer says direct ParaTest bypasses setup. +- `AGENTS.md` documents `test:testbench` as the package-mode Testbench contract run. +- `AGENTS.md` documents that Redis test range must cover the selected ParaTest process count. +- `TestCommandBase::clearEnv()` deletes env-file keys from `$_SERVER`, `$_ENV`, and the putenv adapter. +- `TestCommandBase::clearEnv()` has a concise comment explaining why direct adapter deletion is needed. +- `Hypervel\Testbench\Foundation\EnvironmentFile` owns Testbench env-file source resolution. +- `CopyTestbenchFiles` delegates env-file resolution to `EnvironmentFile` and no longer duplicates candidate-order logic. +- `Bootstrapper::createRuntimeCopy()` copies the resolved package/workbench env file or skeleton fallback into the runtime clone only in package-tester mode. +- Raw PHPUnit Testbench boot does not copy package/workbench `.env` or skeleton `.env.example` through Bootstrapper. +- `package:test` injects command-owned explicit env variables and declared Testbench YAML `env:` values. +- `package:test` does not inject arbitrary parent CLI runtime env variables. +- `defineCacheRoutes()` writes unique route files for each test instance. +- `defineCacheRoutes()` tracks owned route files and deletes only those files during teardown. +- `SyncTestbenchCachedRoutes` still loads `routes/testbench-*.php` files for remote child support, but stale sibling route files are not created by Testbench itself. +- The Testbench README contains the upstream reference and the deliberate env-forwarding difference from Orchestra Testbench. +- `InteractsWithRedis` has no Redis preflight or Redis capacity probing. +- `InteractsWithRedis` and the Horizon worker test bootstrap share Redis worker DB allocation through `RedisTestDatabases`. +- Redis is not connected from command classes before the suite starts. +- Non-parallel `InteractsWithRedis` tests use `REDIS_DB`. +- Parallel `InteractsWithRedis` tests use `REDIS_TEST_DB_MIN` / `REDIS_TEST_DB_MAX`. +- `REDIS_TEST_DB_MIN` defaults to `REDIS_DB`, then `0`. +- `REDIS_TEST_DB_MAX` defaults to `15`. +- Worker overflow fails clearly. +- `getSecondaryRedisDb()` fails clearly without `REDIS_TEST_SECONDARY_DB`. +- `getSecondaryRedisDb()` fails clearly if it equals the current primary test database. +- Configured secondary database is skipped by worker allocation. +- Secondary database tests clean up their own keys and never rely on a suite-level secondary `flushdb()`. +- `.env` and `.env.example` contain the components Redis test range. +- `.github/workflows/redis.yml` sets `REDIS_TEST_SECONDARY_DB` for both Redis and Valkey jobs. +- Boost docs match the existing style and document the user-facing behavior. +- No command-level package `.env` parser was added. +- Testbench YAML `env:` is described as an explicit package-test command channel, not as child Testbench bootstrap behavior. +- Active source/docs contain no stale references to Redis preflight or `bin/paratest`. diff --git a/docs/plans/2026-07-06-testbench-package-mode-dogfood.md b/docs/plans/2026-07-06-testbench-package-mode-dogfood.md new file mode 100644 index 000000000..eaccfd98d --- /dev/null +++ b/docs/plans/2026-07-06-testbench-package-mode-dogfood.md @@ -0,0 +1,882 @@ +# Testbench Package-Mode Dogfood And Serve Options + +## Goal + +Finish the follow-up work exposed by the Testbench package-mode and parallel testing changes: + +- Make root package provider discovery deterministic in package-test workers. +- Add real `serve --host` and `serve --port` options at the Hypervel server command layer. +- Add root Composer binary metadata for packages replaced by `hypervel/components`. +- Add a small real package fixture that dogfoods `vendor/bin/testbench package:test --parallel` from outside the components root. +- Remove the Testbench todo item once the deterministic discovery contract is covered by tests. + +The end state should read as if Hypervel was designed this way from the start. Do not keep stale todo entries, stale docs, dead branches, or duplicated test-only behavior. + +## Research + +### Package Manifest Discovery + +`Hypervel\Testbench\Foundation\PackageManifest` currently includes root Composer metadata only when the current process is the Testbench CLI: + +```php +use function Hypervel\Testbench\is_testbench_cli; +use function Hypervel\Testbench\package_path; + +protected function providersFromTestbench(): ?array +{ + if (is_testbench_cli() && is_file($composerFile = package_path('composer.json'))) { + return $this->files->json($composerFile); + } + + return null; +} +``` + +`is_testbench_cli()` is tied to the `TESTBENCH_CORE` constant. That is correct for remote Testbench CLI children, but it is not enough for PHPUnit / ParaTest workers launched through `package:test`. + +In Hypervel, Testbench copies the skeleton to a per-worker runtime directory. A PHPUnit worker running package tests has `TESTBENCH_PACKAGE_TESTER`, but it does not define `TESTBENCH_CORE`. If the first manifest build happens inside that worker, the runtime clone can build a manifest without the package root metadata. Later remote CLI children can rebuild with root metadata because those children are the Testbench CLI. That makes discovery depend on process order. + +The package-tester signal is already part of Testbench's own runtime contract: + +- `Bootstrapper::createRuntimeCopy()` uses `Env::has('TESTBENCH_PACKAGE_TESTER')` to copy the package environment file into the runtime skeleton. +- `CreatesApplication` and `Foundation\Application` already use the same signal to distinguish package-test runtime behavior. +- `package_path()` resolves through `TESTBENCH_WORKING_PATH`, so root Composer metadata can be read from the package under test rather than the runtime clone. + +`tests/Testbench/Foundation/PackageManifestTest.php` already covers root metadata merging and `dont-discover` filtering, but its anonymous test harness overrides `providersFromTestbench()`. New coverage must hit the real gate. + +### Serve Command + +`Hypervel\Server\Commands\ServerStartCommand` extends Symfony Command directly. It does this because the Swoole server owns the event loop: + +```php +/** + * Extends Symfony Command directly — NOT Hypervel\Console\Command — because the + * Swoole server must own the event loop. Hypervel\Console\Command brings coroutine + * wrapping and signal traits that start the event loop before Server::start(). + */ +#[AsCommand(name: 'serve', description: 'Start Hypervel servers.')] +class ServerStartCommand extends SymfonyCommand +{ + public function __construct(protected Container $container) + { + parent::__construct('serve'); + $this->setDescription('Start Hypervel servers.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + return $this->startServer(); + } + + protected function startServer(): int + { + $serverConfig = $this->container->make('config')->array('server', []); + // ... + $serverFactory->configure($serverConfig); + $serverFactory->start(); + } +} +``` + +Testbench's `serve` command extends the base server command: + +```php +class ServeCommand extends ServerStartCommand +{ + // package-specific bootstrapping, then startServer() +} +``` + +So `--host` and `--port` belong in `ServerStartCommand`, not in Testbench. Testbench should inherit the same options as applications. + +The default server config stores servers under `server.servers`, with the HTTP server marked by `Hypervel\Server\ServerInterface::SERVER_HTTP`: + +```php +[ + 'name' => 'http', + 'type' => ServerInterface::SERVER_HTTP, + 'host' => env('HTTP_SERVER_HOST', '0.0.0.0'), + 'port' => (int) env('HTTP_SERVER_PORT', 9501), +] +``` + +Reverb already mutates `server.servers` before server start so the Swoole server sees all listeners: + +```php +// Register-time config mutation: it must run before ServerStartCommand reads +// `server.servers` and before workers or coroutines exist. +$config->set('server.servers', $servers); +``` + +`serve --host` and `serve --port` should follow the same boot-time-only pattern: mutate config before `ServerFactory::configure()` and before workers start. This does not create runtime config mutation inside a worker. + +`tests/Testbench/CommanderServeTest.php` currently starts the package server by exporting `HTTP_SERVER_HOST` and `HTTP_SERVER_PORT`: + +```php +$process = remote('serve --no-ansi', [ + 'APP_DEBUG' => 'true', + 'APP_ENV' => 'workbench', + 'HTTP_SERVER_HOST' => '127.0.0.1', + 'HTTP_SERVER_PORT' => (string) $serverPort, +]); +``` + +That test should use the command options after they exist. + +### Composer Binary Metadata + +The root `composer.json` replaces both packages that publish binaries: + +```json +"replace": { + "hypervel/facade-documenter": "self.version", + "hypervel/testbench": "self.version" +} +``` + +Their package Composer files declare binaries: + +```json +// src/facade-documenter/composer.json +"bin": [ + "facade.php" +] + +// src/testbench/composer.json +"bin": [ + "bin/testbench" +] +``` + +The root package does not currently declare those binaries. A real package that resolves `hypervel/testbench` through the root `hypervel/components` path repository will not receive `vendor/bin/testbench` from the root package unless root metadata exposes it. + +Root Composer metadata should include both binaries: + +```json +"bin": [ + "src/facade-documenter/facade.php", + "src/testbench/bin/testbench" +] +``` + +### Dogfood Package + +The components test suite now has: + +- Raw framework tests through `composer test` / `composer test:parallel`. +- A scoped Testbench package-mode suite through `composer test:testbench`. + +Those prove the framework's own tests, but they do not prove the real third-party package shape: + +- A package outside the components root. +- Its own `composer.json`. +- A path repository that resolves Hypervel packages from the components checkout. +- `vendor/bin/testbench` installed by Composer. +- Package provider discovery from the package root. +- Workbench provider/config loading. +- Remote Testbench CLI children staying pointed at the package under test. +- Parallel package testing from the package's own working directory. + +The fixture must not live under `tests/` because `phpunit.xml.dist` discovers `./tests` recursively, and `composer test:testbench` passes `tests/Testbench` as a CLI path. A fixture package under that tree would be discovered by the components suite. It also must not live under `src/testbench/` because `src/testbench` is the subtree-split package source. + +Use a top-level fixture directory: + +```text +dogfood/testbench-package/ +``` + +### Clone Dirty Check Diagnostic + +A clone dirty-check diagnostic was discussed as a possible hygiene tool. It would snapshot each Testbench runtime clone between tests and fail on unrestored writes. + +Decision: keep this as a separate explicit owner decision. It is useful as a future opt-in diagnostic, but it is not needed to finish the signed-off package-mode contract. If it is added later, it should be opt-in, live inside Testbench test infrastructure, and have a very small allowlist. If the allowlist grows, the diagnostic is carrying too much policy. + +## Implementation Plan + +### 1. Make Package Manifest Discovery Deterministic + +Update `src/testbench/src/Foundation/PackageManifest.php`. + +Add `Env` usage and include package-tester mode in `providersFromTestbench()`: + +```php +use Hypervel\Testbench\Foundation\Env; + +protected function providersFromTestbench(): ?array +{ + if ((is_testbench_cli() || Env::has('TESTBENCH_PACKAGE_TESTER')) + && is_file($composerFile = package_path('composer.json'))) { + return $this->files->json($composerFile); + } + + return null; +} +``` + +Add a concise source comment because this intentionally differs from Orchestra's persistent-skeleton model: + +```php +// PHPUnit workers are not Testbench CLI processes, but package:test workers +// still need the package root metadata when they build a fresh clone manifest. +``` + +Do not change `getManifest()` filtering. `dont-discover` must remain read-time filtering so the manifest can contain the root package while each Testbench instance decides which packages to expose. + +Keep `tests/Testbench/Foundation/PackageManifestTest.php`'s anonymous manifest harness tests intact. Its existing specific-ignore coverage already proves read-time filtering for root metadata by writing the root package into the cached manifest while `hasPackage()`, `providers()`, and `aliases()` hide it. Tighten that test only if the implementation changes make the assertion unclear. + +Add a subprocess-backed test file for the real env-gate behavior: + +```text +tests/Testbench/Foundation/PackageManifestPackageTesterTest.php +``` + +Do not test this gate in-process. `package_path()` checks the `TESTBENCH_WORKING_PATH` constant before it checks the env repository, and that constant is process-wide. Under `composer test:testbench`, `Hypervel\Testbench\Features\ParallelRunner` defines `TESTBENCH_WORKING_PATH` before tests execute. Under the raw full suite, any earlier Testbench bootstrap in the same worker can define it. `Once::flushState()` cannot fix a defined constant. + +Test the gate through a fresh PHP subprocess for each scenario. The existing fixture already contains the needed package root shape: + +```text +tests/Testbench/Foundation/Fixtures/PackageManifest/ +├── composer.json +└── vendor/composer/installed.json +``` + +Add a small fixture script: + +```text +tests/Testbench/Foundation/Fixtures/PackageManifest/build-manifest.php +``` + +The script should load the components autoloader, optionally define `TESTBENCH_CORE`, construct the real `Hypervel\Testbench\Foundation\PackageManifest` against the fixture base path, and build to a manifest path passed in argv: + +```php +build(); +``` + +The new test file should: + +- Extend `Hypervel\Tests\TestCase`. +- Create a manifest file under `ParallelTesting::tempDir('PackageManifestPackageTesterTest')`. +- Run `build-manifest.php` with Symfony Process. +- Pass `TESTBENCH_WORKING_PATH` as an env var pointing at the fixture path. +- Pass `TESTBENCH_PACKAGE_TESTER=(true)` only for package-tester scenarios. +- Pass `TESTBENCH_PACKAGE_TESTER => false` for the negative and Testbench CLI scenarios. Symfony Process inherits unmentioned parent environment variables, and this test runs under `composer test:testbench` where package-test workers already carry `TESTBENCH_PACKAGE_TESTER=(true)`. +- Run each scenario in a fresh subprocess so `package_path()`, `once()`, and process constants behave exactly as they do in a real child process. +- Write the package manifest to `ParallelTesting::tempDir('PackageManifestPackageTesterTest') . '/packages.php'`, then delete the temp directory in `tearDown()`. + +Test cases: + +```php +#[Test] +public function itAddsRootMetadataWhenRunningInsidePackageTester(): void +{ + // TESTBENCH_PACKAGE_TESTER=true and TESTBENCH_WORKING_PATH points at + // tests/Testbench/Foundation/Fixtures/PackageManifest. + // Build using the real PackageManifest method. + // Assert testbench/example exists in the written manifest. +} + +#[Test] +public function itDoesNotAddRootMetadataOutsideCliAndPackageTesterMode(): void +{ + // TESTBENCH_WORKING_PATH is set, but there is no TESTBENCH_PACKAGE_TESTER + // and the script does not define TESTBENCH_CORE. Pass + // TESTBENCH_PACKAGE_TESTER => false to strip any inherited package-test env. + // Build using the real PackageManifest method. + // Assert testbench/example does not exist in the written manifest. +} + +#[Test] +public function itAddsRootMetadataWhenRunningInsideTheTestbenchCli(): void +{ + // TESTBENCH_WORKING_PATH points at the fixture and the script defines + // TESTBENCH_CORE. Pass TESTBENCH_PACKAGE_TESTER => false so this proves + // the CLI gate rather than the package-tester gate. + // Build using the real PackageManifest method. + // Assert testbench/example exists in the written manifest. +} +``` + +In `tests/Testbench/Foundation/PackageManifestTest.php`, retain read-time filtering coverage through the existing anonymous manifest harness: + +```php +#[Test] +public function itStillFiltersRootMetadataAtReadTime(): void +{ + // Root metadata is written, but ignorePackageDiscoveriesFrom() hides it + // from providers(), aliases(), and hasPackage(). +} +``` + +Use PHPUnit attributes, matching the Testbench package tests. + +Delete the Testbench package manifest item from `docs/todo.md` after coverage lands. + +### 2. Add Server Host And Port Options + +Update `src/server/src/Commands/ServerStartCommand.php`. + +Add Symfony options: + +```php +use Hypervel\Server\ServerInterface; +use Symfony\Component\Console\Input\InputOption; + +protected function configure(): void +{ + $this + ->addOption('host', null, InputOption::VALUE_REQUIRED, 'The host address to serve the application on') + ->addOption('port', null, InputOption::VALUE_REQUIRED, 'The port to serve the application on'); +} +``` + +Pass the input into `startServer()` because the command extends Symfony directly: + +```php +protected function execute(InputInterface $input, OutputInterface $output): int +{ + return $this->startServer($input); +} + +protected function startServer(InputInterface $input): int +{ + // ... +} +``` + +Update Testbench's `ServeCommand` override to call `startServer($input)`. + +Before `ServerFactory::configure()`, apply CLI overrides to the first configured HTTP server: + +```php +$config = $this->container->make('config'); +$serverConfig = $config->array('server', []); + +$host = $input->getOption('host'); +$port = $input->getOption('port'); + +if ($host !== null || $port !== null) { + if ($port !== null && filter_var($port, FILTER_VALIDATE_INT) === false) { + throw new InvalidArgumentException('The serve port must be an integer.'); + } + + $servers = $serverConfig['servers'] ?? []; + $httpServerIndex = null; + + foreach ($servers as $index => $server) { + if (($server['type'] ?? null) === ServerInterface::SERVER_HTTP) { + $httpServerIndex = $index; + break; + } + } + + if ($httpServerIndex === null) { + throw new InvalidArgumentException('Cannot override server host or port because no HTTP server is configured.'); + } + + if ($host !== null) { + $servers[$httpServerIndex]['host'] = (string) $host; + } + + if ($port !== null) { + $servers[$httpServerIndex]['port'] = (int) $port; + } + + $serverConfig['servers'] = $servers; + + // Command options are applied before workers start so ServerFactory and + // later config readers agree on the bound HTTP address. + $config->set('server.servers', $servers); +} +``` + +Use the repository's typed config getter for the initial server config, and mutate config only before server start. Do not mutate config from worker/runtime code. + +Update `tests/Server/ServerStartCommandTest.php`: + +- Keep existing coverage for fail-fast console mode and Symfony boundary. +- Update the start test to include a realistic `servers` entry with `type`, `host`, and `port`. +- Add `set('server.servers', ...)` expectations only in option tests. +- Add tests for: + - No options: existing config is passed to `ServerFactory::configure()` unchanged. + - `--host` and `--port`: first HTTP server is overridden and written back to config. + - Non-integer `--port` fails with a clear exception instead of silently casting to `0`. + - Non-HTTP listeners stay unchanged. + - Options with no HTTP server throw the clear exception. + +Update `tests/Testbench/CommanderServeTest.php`: + +```php +$process = remote("serve --host=127.0.0.1 --port={$serverPort} --no-ansi", [ + 'APP_DEBUG' => 'true', + 'APP_ENV' => 'workbench', +]); +``` + +Do not add host/port options to `watch`. `watch` already has the `watcher.command` config channel for controlling the command it spawns. + +### 3. Add Root Binary Metadata + +Update root `composer.json`: + +```json +"bin": [ + "src/facade-documenter/facade.php", + "src/testbench/bin/testbench" +], +``` + +Place the `bin` key in normal Composer metadata order near `autoload` / `autoload-dev`, consistent with the file's existing structure. + +Do not add a separate facade-documenter test. Composer's binary behavior for Testbench is proved by the dogfood fixture. The facade-documenter entry is the same metadata shape and does not need a fake package just for that binary. + +### 4. Add A Real Dogfood Package + +Create this directory: + +```text +dogfood/testbench-package/ +``` + +Add a package `composer.json`: + +```json +{ + "name": "hypervel/dogfood-testbench-package", + "type": "library", + "description": "Dogfood package for Hypervel Testbench package-mode tests.", + "license": "MIT", + "autoload": { + "psr-4": { + "Hypervel\\Dogfood\\TestbenchPackage\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Hypervel\\Dogfood\\TestbenchPackage\\Tests\\": "tests/", + "Workbench\\App\\": "workbench/app/" + } + }, + "repositories": [ + { + "type": "path", + "url": "../..", + "options": { + "symlink": true + } + } + ], + "require": { + "php": "^8.4", + "hypervel/console": "0.4.x-dev", + "hypervel/contracts": "0.4.x-dev", + "hypervel/support": "0.4.x-dev" + }, + "require-dev": { + "brianium/paratest": "^7.19", + "fakerphp/faker": "^1.24", + "hypervel/components": "0.4.x-dev", + "hypervel/testbench": "0.4.x-dev", + "mockery/mockery": "1.6.x-dev", + "phpunit/phpunit": "^13.0.3", + "symfony/yaml": "^8.0.12" + }, + "extra": { + "hypervel": { + "providers": [ + "Hypervel\\Dogfood\\TestbenchPackage\\DogfoodServiceProvider" + ] + } + }, + "scripts": { + "test": "testbench package:test --parallel" + }, + "minimum-stability": "dev", + "prefer-stable": true +} +``` + +Use a single path repository pointing at the components root. The dogfood fixture requires `hypervel/components` in dev so Composer selects the path package; once selected, the root package's `replace` map satisfies split component dependencies and the new root `bin` metadata gives the fixture `vendor/bin/testbench`. + +Declare PHPUnit, ParaTest, Faker, Mockery, and Symfony YAML directly in the fixture's `require-dev`. `hypervel/testbench` is satisfied by the selected root `hypervel/components` package through `replace`, so Testbench's split-package dependency list does not enter dependency resolution. A real package author also needs test runner dependencies in this topology, and the fixture should prove that shape honestly. + +Move the committed Testbench Workbench namespaces to dev autoload in both the components root `composer.json` and `src/testbench/composer.json`. + +In the components root package: + +```json +"autoload-dev": { + "psr-4": { + "Workbench\\App\\": "src/testbench/workbench/app/", + "Workbench\\Database\\Factories\\": "src/testbench/workbench/database/factories/", + "Workbench\\Database\\Seeders\\": "src/testbench/workbench/database/seeders/" + } +} +``` + +In the split Testbench package: + +```json +"autoload-dev": { + "psr-4": { + "Workbench\\App\\": "workbench/app/", + "Workbench\\Database\\Factories\\": "workbench/database/factories/", + "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" + } +} +``` + +The Workbench shipped with Testbench is for framework and package development. Consumers own their `Workbench\*` namespaces, and `InstallCommand` already writes consumer Workbench mappings into the package's autoload-dev section. Keeping the framework Workbench in production autoload would leak Hypervel's committed Workbench classes into every package install and collide with real package Workbench providers. + +Do not commit `dogfood/testbench-package/composer.lock`, `dogfood/testbench-package/vendor/`, or the fixture PHPUnit cache directory. + +Add `.gitignore` entries: + +```gitignore +/dogfood/testbench-package/.phpunit.cache/ +/dogfood/testbench-package/composer.lock +/dogfood/testbench-package/vendor/ +``` + +Update `.php-cs-fixer.php` so code style runs never traverse third-party fixture dependencies: + +```php +->exclude('dogfood/testbench-package/vendor') +``` + +Add `phpunit.xml.dist`: + +```xml + + + + + tests + + + + + + + +``` + +Add package source: + +```php +app->make('config')->set('dogfood.package_provider_loaded', true); + $this->commands([DogfoodProbeCommand::class]); + } +} +``` + +Add a command used by remote CLI tests: + +```php +line($config->boolean('dogfood.package_provider_loaded', false) ? 'package-provider' : 'missing-package-provider'); + $this->line($config->boolean('dogfood.workbench_provider_loaded', false) ? 'workbench-provider' : 'missing-workbench-provider'); + + return self::SUCCESS; + } +} +``` + +Add `testbench.yaml`: + +```yaml +providers: + - Workbench\App\Providers\WorkbenchServiceProvider + +workbench: + install: true + discovers: + config: true +``` + +Add a workbench provider: + +```php +app->make('config')->set('dogfood.workbench_provider_loaded', true); + } +} +``` + +Add a workbench config file to prove config discovery: + +```text +dogfood/testbench-package/workbench/config/dogfood.php +``` + +```php + true, +]; +``` + +Add tests under the dogfood package's own `tests/` directory: + +```php +assertTrue($this->app->make('config')->boolean('dogfood.package_provider_loaded')); + } + + #[Test] + public function itLoadsWorkbenchProviderAndConfig(): void + { + $config = $this->app->make('config'); + + $this->assertTrue($config->boolean('dogfood.workbench_provider_loaded')); + $this->assertTrue($config->boolean('dogfood.workbench_config_loaded')); + } + + #[Test] + public function itRunsRemoteCommandsInsideThePackageRuntime(): void + { + $process = remote('dogfood:probe --no-ansi')->mustRun(); + + $this->assertStringContainsString('package-provider', $process->getOutput()); + $this->assertStringContainsString('workbench-provider', $process->getOutput()); + } + + #[Test] + public function itRunsInsideParallelPackageMode(): void + { + $this->assertNotSame('', (string) ($_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? '')); + $this->assertSame('(true)', (string) ($_SERVER['TESTBENCH_PACKAGE_TESTER'] ?? $_ENV['TESTBENCH_PACKAGE_TESTER'] ?? '')); + } +} +``` + +Add a root Composer script: + +```json +"test:dogfood": [ + "Composer\\Config::disableProcessTimeout", + "composer --working-dir=dogfood/testbench-package install --prefer-dist -n -o", + "composer --working-dir=dogfood/testbench-package test" +] +``` + +Wire it into root scripts: + +```json +"check": [ + "@test", + "@test:testbench", + "@test:dogfood", + "@analyse", + "@lint" +], +"fix": [ + "@lint:fix", + "@analyse", + "@test:parallel", + "@test:testbench", + "@test:dogfood" +] +``` + +Add a CI step after the scoped Testbench package-mode suite: + +```yaml +- name: Run Testbench dogfood package suite + run: composer test:dogfood +``` + +This makes CI prove both the components-owned Testbench suite and real package usage. + +Do not add the dogfood fixture to `phpstan.neon.dist`. Root phpstan currently analyses source paths, not tests. The dogfood package is test infrastructure, and its own coverage comes from running its package test suite through `composer test:dogfood`. + +### 5. Update Documentation + +Update `src/testbench/README.md` under "Differences From Orchestra Testbench" with the package-tester manifest gate difference: + +```md +Hypervel includes root package discovery metadata when a `package:test` worker builds the Testbench package manifest. Orchestra's persistent skeleton can be seeded by the parent Testbench CLI process, while Hypervel's per-worker runtime skeletons may build their manifests directly inside PHPUnit / ParaTest workers. +``` + +Update `src/boost/docs/testbench.md` in the same style as the existing doc: + +- In "Serving the Workbench Application", mention `--host` and `--port`: + +```shell +vendor/bin/testbench serve --host=127.0.0.1 --port=9502 +``` + +- Keep the wording short and practical: + +```md +You may pass `--host` and `--port` to temporarily override the configured HTTP server address for the current process. +``` + +- In "Running Package Tests", keep the existing statement that package tests use the same parallel database, cache, and Redis isolation behavior as application tests. No new split-run explanation is needed for package authors. + +Update server docs: + +- `src/boost/docs/installation.md`: after `php artisan serve`, mention `php artisan serve --host=127.0.0.1 --port=9502` as a temporary override, while `config/server.php` remains the main configuration source. +- `src/boost/docs/deployment.md`: mention the flags in the "Running the Hypervel Server" section without implying they are preferred over production config. +- `src/boost/docs/lifecycle.md`: mention that `serve` reads `config/server.php` and command flags are applied before Swoole workers start. + +Remove the package-test discovery todo from `docs/todo.md` after the implementation and tests prove the contract. + +## Testing Plan + +Run tests incrementally after each logical test file or group: + +1. Package manifest: + +```shell +./vendor/bin/phpunit --no-progress tests/Testbench/Foundation/PackageManifestTest.php +./vendor/bin/phpunit --no-progress tests/Testbench/Foundation/PackageManifestPackageTesterTest.php +``` + +2. Server command: + +```shell +./vendor/bin/phpunit --no-progress tests/Server/ServerStartCommandTest.php +``` + +3. Commander serve: + +```shell +./vendor/bin/phpunit --no-progress tests/Testbench/CommanderServeTest.php +``` + +4. Dogfood package: + +```shell +composer test:dogfood +``` + +5. Scoped Testbench package-mode suite: + +```shell +composer test:testbench +``` + +6. Full repository checks: + +```shell +composer fix +``` + +`composer fix` runs code style, phpstan, raw ParaTest, the scoped Testbench package-mode suite, and the dogfood package suite after this plan is implemented. + +## Self-Review Checklist + +Before requesting code review after implementation: + +- Confirm `PackageManifest::providersFromTestbench()` has exactly two valid gates: Testbench CLI and package-test worker. +- Confirm package-test workers and remote CLI children produce the same manifest content for root metadata. +- Confirm read-time `dont-discover` still hides ignored packages without changing the built manifest. +- Confirm `src/testbench/README.md` records the intentional Orchestra difference next to the other Testbench differences. +- Confirm no Testbench behavior changes for non-package-test raw Testbench usage. +- Confirm `serve --host` and `serve --port` mutate only the HTTP server entry, not WebSocket or base server entries. +- Confirm config mutation happens before `ServerFactory::configure()` and before Swoole workers start. +- Confirm `CommanderServeTest` no longer relies on `HTTP_SERVER_HOST` / `HTTP_SERVER_PORT`. +- Confirm `.php-cs-fixer.php` excludes `dogfood/testbench-package/vendor`. +- Confirm root Composer bin metadata creates `vendor/bin/testbench` for the dogfood fixture. +- Confirm the dogfood fixture declares its own PHPUnit, ParaTest, and Symfony YAML dev dependencies. +- Confirm the dogfood fixture registers `Hypervel\Testing\PHPUnit\AfterEachTestExtension` in `phpunit.xml.dist`. +- Confirm the dogfood fixture is not discovered by the components root PHPUnit suite. +- Confirm the dogfood package proves root provider discovery, workbench provider/config loading, remote command behavior, and parallel package mode. +- Confirm `docs/todo.md` has no stale Testbench package-discovery item. +- Confirm docs use the same tone and structure as nearby sections. +- Confirm `composer fix` is green. diff --git a/dogfood/testbench-package/composer.json b/dogfood/testbench-package/composer.json new file mode 100644 index 000000000..5c4dfaf16 --- /dev/null +++ b/dogfood/testbench-package/composer.json @@ -0,0 +1,53 @@ +{ + "name": "hypervel/dogfood-testbench-package", + "type": "library", + "description": "Dogfood package for Hypervel Testbench package-mode tests.", + "license": "MIT", + "autoload": { + "psr-4": { + "Hypervel\\Dogfood\\TestbenchPackage\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Hypervel\\Dogfood\\TestbenchPackage\\Tests\\": "tests/", + "Workbench\\App\\": "workbench/app/" + } + }, + "repositories": [ + { + "type": "path", + "url": "../..", + "options": { + "symlink": true + } + } + ], + "require": { + "php": "^8.4", + "hypervel/console": "0.4.x-dev", + "hypervel/contracts": "0.4.x-dev", + "hypervel/support": "0.4.x-dev" + }, + "require-dev": { + "brianium/paratest": "^7.19", + "fakerphp/faker": "^1.24", + "hypervel/components": "0.4.x-dev", + "hypervel/testbench": "0.4.x-dev", + "mockery/mockery": "1.6.x-dev", + "phpunit/phpunit": "^13.0.3", + "symfony/yaml": "^8.0.12" + }, + "extra": { + "hypervel": { + "providers": [ + "Hypervel\\Dogfood\\TestbenchPackage\\DogfoodServiceProvider" + ] + } + }, + "scripts": { + "test": "testbench package:test --parallel" + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/dogfood/testbench-package/phpunit.xml.dist b/dogfood/testbench-package/phpunit.xml.dist new file mode 100644 index 000000000..e4b66f657 --- /dev/null +++ b/dogfood/testbench-package/phpunit.xml.dist @@ -0,0 +1,18 @@ + + + + + tests + + + + + + + diff --git a/dogfood/testbench-package/src/DogfoodProbeCommand.php b/dogfood/testbench-package/src/DogfoodProbeCommand.php new file mode 100644 index 000000000..59874e942 --- /dev/null +++ b/dogfood/testbench-package/src/DogfoodProbeCommand.php @@ -0,0 +1,26 @@ +line($config->boolean('dogfood.package_provider_loaded', false) ? 'package-provider' : 'missing-package-provider'); + $this->line($config->boolean('dogfood.workbench_provider_loaded', false) ? 'workbench-provider' : 'missing-workbench-provider'); + + return self::SUCCESS; + } +} diff --git a/dogfood/testbench-package/src/DogfoodServiceProvider.php b/dogfood/testbench-package/src/DogfoodServiceProvider.php new file mode 100644 index 000000000..b178d4fdd --- /dev/null +++ b/dogfood/testbench-package/src/DogfoodServiceProvider.php @@ -0,0 +1,19 @@ +app->make('config')->set('dogfood.package_provider_loaded', true); + $this->commands([DogfoodProbeCommand::class]); + } +} diff --git a/dogfood/testbench-package/testbench.yaml b/dogfood/testbench-package/testbench.yaml new file mode 100644 index 000000000..f8c14e38c --- /dev/null +++ b/dogfood/testbench-package/testbench.yaml @@ -0,0 +1,7 @@ +providers: + - Workbench\App\Providers\WorkbenchServiceProvider + +workbench: + install: true + discovers: + config: true diff --git a/dogfood/testbench-package/tests/PackageRuntimeTest.php b/dogfood/testbench-package/tests/PackageRuntimeTest.php new file mode 100644 index 000000000..a4926617b --- /dev/null +++ b/dogfood/testbench-package/tests/PackageRuntimeTest.php @@ -0,0 +1,47 @@ +assertTrue($this->app->make('config')->boolean('dogfood.package_provider_loaded')); + } + + #[Test] + public function itLoadsWorkbenchProviderAndConfig(): void + { + $config = $this->app->make('config'); + + $this->assertTrue($config->boolean('dogfood.workbench_provider_loaded')); + $this->assertTrue($config->boolean('dogfood.workbench_config_loaded')); + } + + #[Test] + public function itRunsRemoteCommandsInsideThePackageRuntime(): void + { + $process = remote('dogfood:probe --no-ansi')->mustRun(); + + $this->assertStringContainsString('package-provider', $process->getOutput()); + $this->assertStringContainsString('workbench-provider', $process->getOutput()); + } + + #[Test] + public function itRunsInsideParallelPackageMode(): void + { + $this->assertNotSame('', (string) ($_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? '')); + $this->assertSame('(true)', (string) ($_SERVER['TESTBENCH_PACKAGE_TESTER'] ?? $_ENV['TESTBENCH_PACKAGE_TESTER'] ?? '')); + } +} diff --git a/dogfood/testbench-package/workbench/app/Providers/WorkbenchServiceProvider.php b/dogfood/testbench-package/workbench/app/Providers/WorkbenchServiceProvider.php new file mode 100644 index 000000000..6a548d271 --- /dev/null +++ b/dogfood/testbench-package/workbench/app/Providers/WorkbenchServiceProvider.php @@ -0,0 +1,18 @@ +app->make('config')->set('dogfood.workbench_provider_loaded', true); + } +} diff --git a/dogfood/testbench-package/workbench/config/dogfood.php b/dogfood/testbench-package/workbench/config/dogfood.php new file mode 100644 index 000000000..820e8a10e --- /dev/null +++ b/dogfood/testbench-package/workbench/config/dogfood.php @@ -0,0 +1,7 @@ + true, +]; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c7f631f2a..73784f4a4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -26,5 +26,6 @@ + diff --git a/src/boost/docs/deployment.md b/src/boost/docs/deployment.md index 78f32ccad..916db5c5e 100644 --- a/src/boost/docs/deployment.md +++ b/src/boost/docs/deployment.md @@ -128,6 +128,8 @@ php artisan serve By default, the HTTP server binds to `0.0.0.0:9501` with HTTP/2 enabled. You may configure the server host, port, worker count, max requests per worker, HTTP/2 support, and other Swoole settings using the `HTTP_SERVER_HOST`, `HTTP_SERVER_PORT`, `SERVER_WORKERS`, `SERVER_MAX_REQUESTS`, and `SERVER_HTTP2` environment variables read by `config/server.php`. +The `serve` command also accepts `--host` and `--port` options for overriding the HTTP server address for the current process. In production, prefer durable configuration in `config/server.php` and your environment. + ### Directory Permissions diff --git a/src/boost/docs/installation.md b/src/boost/docs/installation.md index b7ae04c21..9667c5372 100644 --- a/src/boost/docs/installation.md +++ b/src/boost/docs/installation.md @@ -103,6 +103,12 @@ php artisan serve Once you have started the development server, your application will be accessible in your web browser at [http://localhost:9501](http://localhost:9501). The server host, port, worker count, and other Swoole options may be configured in your application's `config/server.php` file. +You may temporarily override the HTTP server address for the current process using the `--host` and `--port` options: + +```shell +php artisan serve --host=127.0.0.1 --port=9502 +``` + ### Watching for Changes diff --git a/src/boost/docs/lifecycle.md b/src/boost/docs/lifecycle.md index 1d54afbea..49c7dc50b 100644 --- a/src/boost/docs/lifecycle.md +++ b/src/boost/docs/lifecycle.md @@ -29,7 +29,7 @@ The entry point for a Hypervel application is the `artisan` executable. This fil The `bootstrap/app.php` file creates and configures the application using `Hypervel\Foundation\Application`. This is where routing, middleware, exception handling, service providers, and console command paths are configured before the application instance is returned. -When you run `php artisan serve`, the console kernel resolves the `serve` command, which starts the configured Swoole server. By default, the HTTP server is configured in your application's `config/server.php` file and listens on port `9501`. +When you run `php artisan serve`, the console kernel resolves the `serve` command, which starts the configured Swoole server. By default, the HTTP server is configured in your application's `config/server.php` file and listens on port `9501`. Any `serve` command host or port options are applied before Swoole workers start. ### Bootstrapping the Application diff --git a/src/boost/docs/testbench.md b/src/boost/docs/testbench.md index 219d17024..49857b989 100644 --- a/src/boost/docs/testbench.md +++ b/src/boost/docs/testbench.md @@ -969,6 +969,12 @@ composer run serve The `serve` command uses Hypervel's normal server configuration and starts the same Swoole server used by a Hypervel application. +You may pass `--host` and `--port` to temporarily override the configured HTTP server address for the current process: + +```shell +vendor/bin/testbench serve --host=127.0.0.1 --port=9502 +``` + > [!NOTE] > Unlike Orchestra Testbench, Hypervel's `serve` command does not provide preview-only conveniences such as a welcome page or automatic login. @@ -1021,6 +1027,8 @@ To run tests in parallel, pass the `--parallel` option: vendor/bin/testbench package:test --parallel ``` +Package tests use the same parallel database, cache, and Redis isolation behavior as application tests. + The command supports the following options: | Option | Description | diff --git a/src/boost/docs/testing.md b/src/boost/docs/testing.md index 4b6c4ff2c..a7783c4cd 100644 --- a/src/boost/docs/testing.md +++ b/src/boost/docs/testing.md @@ -280,6 +280,33 @@ If you need to run tests in parallel without automatically configuring parallel php artisan test --parallel --without-databases --without-cache ``` + +#### Parallel Testing and Redis + +When your tests use Hypervel's `InteractsWithRedis` testing trait, Hypervel will use your normal configured Redis database when the tests are not running in parallel. When tests are running in parallel, the trait may assign each parallel worker its own Redis database so calls such as `flushdb` remain isolated from the other workers. + +Parallel Redis databases are selected from the `REDIS_TEST_DB_MIN` and `REDIS_TEST_DB_MAX` environment variables. By default, `REDIS_TEST_DB_MIN` uses your configured `REDIS_DB` value and `REDIS_TEST_DB_MAX` is `15`: + +```ini +REDIS_DB=1 +REDIS_TEST_DB_MIN=1 +REDIS_TEST_DB_MAX=15 +``` + +If Hypervel cannot assign a Redis database to a worker, the test run will fail. ParaTest uses your machine's CPU count by default, so make sure your Redis test range covers the number of workers you are running or pass an explicit process count: + +```shell +php artisan test --parallel --processes=4 +``` + +Some low-level Redis tests may need to switch to a second Redis database with `select`. You may reserve that database using `REDIS_TEST_SECONDARY_DB`: + +```ini +REDIS_TEST_SECONDARY_DB=15 +``` + +When a secondary database is configured inside the worker range, Hypervel skips it when assigning worker databases. Tests that use a shared secondary database should use unique keys and delete them when the test finishes. + #### Parallel Testing Hooks @@ -304,29 +331,29 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - ParallelTesting::setUpProcess(function (int $token) { + ParallelTesting::setUpProcess(function (string $token) { // ... }); - ParallelTesting::setUpTestCase(function (int $token, TestCase $testCase) { + ParallelTesting::setUpTestCase(function (string $token, TestCase $testCase) { // ... }); // Executed after a test database is created and before migrations run... - ParallelTesting::setUpTestDatabaseBeforeMigrating(function (string $database, int $token) { + ParallelTesting::setUpTestDatabaseBeforeMigrating(function (string $database, string $token) { // ... }); // Executed when a test database has been migrated... - ParallelTesting::setUpTestDatabase(function (string $database, int $token) { + ParallelTesting::setUpTestDatabase(function (string $database, string $token) { Artisan::call('db:seed'); }); - ParallelTesting::tearDownTestCase(function (int $token, TestCase $testCase) { + ParallelTesting::tearDownTestCase(function (string $token, TestCase $testCase) { // ... }); - ParallelTesting::tearDownProcess(function (int $token) { + ParallelTesting::tearDownProcess(function (string $token) { // ... }); } diff --git a/src/di/src/Aop/Ast.php b/src/di/src/Aop/Ast.php index 31f72b259..46755d2fe 100644 --- a/src/di/src/Aop/Ast.php +++ b/src/di/src/Aop/Ast.php @@ -39,9 +39,9 @@ public function parse(string $code): ?array * Reads the class source file, applies all registered AST visitors * (via AstVisitorRegistry), and returns the modified PHP code. */ - public function proxy(string $className): string + public function proxy(string $className, ?string $sourceFilePath = null): string { - $code = $this->getCodeByClassName($className); + $code = $this->getCodeByClassName($className, $sourceFilePath); $stmts = $this->astParser->parse($code); $traverser = new NodeTraverser; $visitorMetadata = new VisitorMetadata($className); @@ -78,9 +78,9 @@ public function parseClassByStmts(array $stmts): string /** * Read the source code for a class from its file. */ - private function getCodeByClassName(string $className): string + private function getCodeByClassName(string $className, ?string $sourceFilePath = null): string { - $file = Composer::getLoader()->findFile($className); + $file = $sourceFilePath ?? Composer::getLoader()->findFile($className); if (! $file) { return ''; } diff --git a/src/di/src/Aop/ProxyManager.php b/src/di/src/Aop/ProxyManager.php index 782ba3d5e..71b729a77 100644 --- a/src/di/src/Aop/ProxyManager.php +++ b/src/di/src/Aop/ProxyManager.php @@ -8,6 +8,13 @@ class ProxyManager { + /** + * Source paths used to generate existing proxy files. + * + * @var array className => sourceFilePath + */ + protected static array $generatedFrom = []; + /** * The classes that have been rewritten as proxies. * @@ -92,28 +99,34 @@ protected function generateProxyFiles(array $proxies = []): array protected function putProxyFile(Ast $ast, string $className): string { $proxyFilePath = $this->getProxyFilePath($className); + $sourceFilePath = $this->classMap[$className]; $modified = true; if (file_exists($proxyFilePath)) { - $modified = $this->isModified($className, $proxyFilePath); + $modified = $this->isModified($className, $sourceFilePath, $proxyFilePath); } if ($modified) { - $code = $ast->proxy($className); + $code = $ast->proxy($className, $sourceFilePath); file_put_contents($proxyFilePath, $code); } + static::$generatedFrom[$className] = $sourceFilePath; + return $proxyFilePath; } /** * Determine if the source class has been modified since the proxy was generated. */ - protected function isModified(string $className, ?string $proxyFilePath = null): bool + protected function isModified(string $className, string $sourceFilePath, ?string $proxyFilePath = null): bool { $proxyFilePath = $proxyFilePath ?? $this->getProxyFilePath($className); + if (isset(static::$generatedFrom[$className]) && static::$generatedFrom[$className] !== $sourceFilePath) { + return true; + } + $time = $this->filesystem->lastModified($proxyFilePath); - $origin = $this->classMap[$className]; - if ($time >= $this->filesystem->lastModified($origin)) { + if ($time >= $this->filesystem->lastModified($sourceFilePath)) { return false; } @@ -172,4 +185,16 @@ protected function initProxiesByReflectionClassMap(array $reflectionClassMap = [ } return $proxies; } + + /** + * Flush generated proxy source tracking. + * + * Tests only. Do not register this with the global after-test subscriber: + * proxy files can persist in a worker's runtime skeleton between tests, + * and clearing this map would hide source-path changes behind mtime checks. + */ + public static function flushState(): void + { + static::$generatedFrom = []; + } } diff --git a/src/di/src/Bootstrap/GenerateProxies.php b/src/di/src/Bootstrap/GenerateProxies.php index 5aee60313..8525d8be3 100644 --- a/src/di/src/Bootstrap/GenerateProxies.php +++ b/src/di/src/Bootstrap/GenerateProxies.php @@ -21,6 +21,13 @@ */ class GenerateProxies { + /** + * The source class map used for proxy generation. + * + * @var null|array + */ + protected static ?array $sourceClassMap = null; + /** * Bootstrap the AOP proxy generation. */ @@ -58,7 +65,7 @@ public function bootstrap(ApplicationContract $app): void protected function buildClassMap(): array { $loader = Composer::getLoader(); - $classMap = $loader->getClassMap(); + $classMap = $this->mergeSourceClassMap($loader->getClassMap()); foreach (AspectCollector::getRules() as $rule) { foreach ($rule['classes'] as $classRule) { @@ -72,7 +79,9 @@ protected function buildClassMap(): array if (! isset($classMap[$className])) { $file = $loader->findFile($className); - if ($file !== false) { + if ($file !== false && ! str_ends_with($file, '.proxy.php')) { + // A flushed source map can leave a proxied PSR-4 class visible only through Composer's proxy entry. + static::$sourceClassMap[$className] = $file; $classMap[$className] = $file; } } @@ -81,4 +90,43 @@ protected function buildClassMap(): array return $classMap; } + + /** + * Merge Composer's current class map into the proxy source map. + * + * Runtime class-map overrides are legitimate source entries and may be + * registered after the first application boot. Generated proxy paths are + * excluded because they point at boot artifacts, not source files. + * + * @param array $classMap + * @return array + */ + protected function mergeSourceClassMap(array $classMap): array + { + static::$sourceClassMap ??= []; + + foreach ($classMap as $class => $path) { + if (! str_ends_with($path, '.proxy.php')) { + static::$sourceClassMap[$class] = $path; + } + } + + return static::$sourceClassMap; + } + + /** + * Flush the captured source class map. + * + * Tests only. This is for tests that swap Composer's loader before + * proxy generation; do not register it with the global after-test + * subscriber because normal test cleanup runs after proxy paths have + * already been added to the loader. Flushing mid-worker loses accumulated + * findFile() resolutions for already-proxied PSR-4 classes, so those + * classes are skipped from regeneration until the loader is swapped or the + * process restarts. + */ + public static function flushState(): void + { + static::$sourceClassMap = null; + } } diff --git a/src/foundation/src/Testing/Concerns/InteractsWithRedis.php b/src/foundation/src/Testing/Concerns/InteractsWithRedis.php index bf366d5bf..b9181059e 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithRedis.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithRedis.php @@ -4,9 +4,12 @@ namespace Hypervel\Foundation\Testing\Concerns; +use Hypervel\Container\Container; +use Hypervel\Foundation\Testing\RedisTestDatabases; use Hypervel\Redis\Pool\PoolFactory; use Hypervel\Redis\RedisConfig; use Hypervel\Support\Facades\Redis; +use Hypervel\Testing\ParallelTesting; use Throwable; /** @@ -21,22 +24,21 @@ * * Parallel Testing (ParaTest): * Each ParaTest worker gets its own Redis DB number to prevent cross-process - * interference. The DB is computed as REDIS_DB + TEST_TOKEN, where TEST_TOKEN - * is set by ParaTest (1, 2, 3...). Sequential runs use REDIS_DB directly. + * interference. Sequential runs use REDIS_DB directly. Parallel runs allocate + * worker databases from REDIS_TEST_DB_MIN through REDIS_TEST_DB_MAX. * - * The base DB (REDIS_DB) is reserved as a shared secondary DB for tests that - * need to call select() to switch databases. No parallel worker uses it as - * their primary, so it is never flushed during a parallel run. Tests needing - * a secondary DB should use getSecondaryRedisDb() instead of hardcoding a - * DB number. - * - * If a worker's TEST_TOKEN exceeds the available Redis databases, its Redis - * tests are skipped (non-Redis tests still run on that worker). + * Tests that need to call select() to switch databases should set + * REDIS_TEST_SECONDARY_DB and use getSecondaryRedisDb(). The secondary DB is + * shared by tests that explicitly request it; never call flushdb() on it. + * Use unique keys and clean them up with del(). * * Environment Variables: * - REDIS_HOST: Redis host (default: 127.0.0.1) * - REDIS_PORT: Redis port (default: 6379) - * - REDIS_DB: Base Redis database number (default: 1) + * - REDIS_DB: Base Redis database number (default: 0) + * - REDIS_TEST_DB_MIN: First Redis database available for parallel workers (default: REDIS_DB) + * - REDIS_TEST_DB_MAX: Last Redis database available for parallel workers (default: 15) + * - REDIS_TEST_SECONDARY_DB: Shared secondary database for select() tests (optional) * - REDIS_PASSWORD: Redis password (optional) */ trait InteractsWithRedis @@ -46,11 +48,6 @@ trait InteractsWithRedis */ private static bool $connectionFailedOnceWithDefaultsSkip = false; - /** - * Indicates if no Redis DB is available for this parallel worker (overflow). - */ - private static bool $noRedisDbAvailable = false; - /** * Set up Redis for testing (auto-called by setUpTraits). * @@ -58,8 +55,8 @@ trait InteractsWithRedis * - Only skips if using default host/port AND no explicit REDIS_HOST env var * - If explicit config exists and fails, the exception propagates (misconfiguration) * - * When running under ParaTest, assigns a per-worker Redis DB number to - * prevent cross-process interference. + * When running under ParaTest, assigns a configured per-worker Redis DB + * number to prevent cross-process interference. */ protected function setUpInteractsWithRedis(): void { @@ -69,12 +66,6 @@ protected function setUpInteractsWithRedis(): void ); } - if (static::$noRedisDbAvailable) { - $this->markTestSkipped( - 'No Redis database available for this parallel worker. Reduce paratest -p or increase Redis databases.' - ); - } - // Apply per-worker DB number for parallel isolation (no-op in sequential mode) $this->configureParallelRedisDb(); @@ -147,20 +138,47 @@ protected function hasExplicitRedisConfig(): bool */ protected function getBaseRedisDb(): int { - return (int) env('REDIS_DB', 0); + return RedisTestDatabases::baseDatabase(); } /** - * Get the primary Redis DB number for the current parallel test worker. - * - * Sequential (no TEST_TOKEN): returns REDIS_DB (default 1). - * Parallel (TEST_TOKEN=N): returns REDIS_DB + N. + * Get the first Redis DB number available for parallel test workers. + */ + protected function getRedisTestDbMin(): int + { + return RedisTestDatabases::minimumDatabase(); + } + + /** + * Get the last Redis DB number available for parallel test workers. + */ + protected function getRedisTestDbMax(): int + { + return RedisTestDatabases::maximumDatabase(); + } + + /** + * Get the configured secondary Redis DB number. + */ + protected function getConfiguredSecondaryRedisDb(): ?int + { + return RedisTestDatabases::configuredSecondaryDatabase(); + } + + /** + * Get the primary Redis DB number for the current test worker. */ protected function getParallelRedisDb(): int { - $token = env('TEST_TOKEN'); + return RedisTestDatabases::primaryDatabase($this->parallelTestingToken()); + } - return $this->getBaseRedisDb() + ($token !== null ? (int) $token : 0); + /** + * Get the primary Redis DB number for a parallel testing token. + */ + protected function redisDatabaseForParallelToken(string $token): int + { + return RedisTestDatabases::databaseForToken($token); } /** @@ -168,83 +186,74 @@ protected function getParallelRedisDb(): int * * Must always return a DB number different from getParallelRedisDb(). * - * Parallel mode: returns the base DB (REDIS_DB). No worker uses it as - * their primary (workers start at base + 1), so it is never flushed - * during a parallel run — safe for shared use with unique keys. - * - * Sequential mode: returns base + 1, since the primary IS the base DB - * and we need a different one. No conflict because there are no workers. - * - * IMPORTANT: This DB is shared across all parallel workers. Never call - * flushdb() on it — use unique keys (e.g. uniqid()) and clean up via - * del() instead. + * This DB is shared across all parallel workers. Never call flushdb() on + * it — use unique keys and clean up via del() instead. */ protected function getSecondaryRedisDb(): int { - $base = $this->getBaseRedisDb(); - - if (env('TEST_TOKEN') !== null) { - return $base; - } - - // Sequential: primary == base, so use the next DB up - return $base + 1; + return RedisTestDatabases::secondaryDatabase($this->parallelTestingToken()); } /** * Configure the Redis DB number for parallel test isolation. * * Sets the database.redis.default.database config to the per-worker DB number. - * On the first call per process, also checks whether the DB number is - * within Redis's configured database limit. */ private function configureParallelRedisDb(): void { - if (env('TEST_TOKEN') === null) { + $token = $this->parallelTestingToken(); + + if ($token === false) { return; } - $db = $this->getParallelRedisDb(); + $database = $this->redisDatabaseForParallelToken($token); - // Check overflow on the first Redis test in this worker - if (static::$noRedisDbAvailable === false && ! $this->isRedisDbAvailable($db)) { - static::$noRedisDbAvailable = true; - $this->markTestSkipped( - "No Redis database available for this parallel worker (need DB {$db}). " - . 'Reduce paratest -p or increase Redis databases.' - ); - } + $this->app->make('config')->set('database.redis.default.database', $database); + } - $this->app->make('config')->set('database.redis.default.database', $db); + /** + * Get the current parallel testing token. + */ + protected function parallelTestingToken(): string|false + { + // Testbench defineEnvironment() hooks can ask for Redis DBs before + // refreshApplication() assigns the created application to the test case. + return ($this->app ?? Container::getInstance())->make(ParallelTesting::class)->token(); } /** - * Check if the given Redis DB number is within the server's configured limit. + * Get the configured Redis worker databases. + * + * @return array */ - private function isRedisDbAvailable(int $db): bool + protected function redisWorkerDatabases(): array { - try { - $client = new \Redis; - $client->connect( - env('REDIS_HOST', '127.0.0.1'), - (int) env('REDIS_PORT', 6379) - ); + return RedisTestDatabases::workerDatabases(); + } - $auth = env('REDIS_PASSWORD'); - if ($auth) { - $client->auth($auth); - } + /** + * Get the zero-based Redis worker index for a ParaTest token. + */ + protected function redisWorkerIndex(string $token): int + { + return RedisTestDatabases::workerIndex($token); + } - $config = $client->config('GET', 'databases'); - $maxDatabases = (int) ($config['databases'] ?? 16); - $client->close(); + /** + * Get a non-negative integer Redis environment value. + */ + protected function integerRedisEnvironment(string $key, int $default): int + { + return RedisTestDatabases::integerEnvironment($key, $default); + } - return $db < $maxDatabases; - } catch (Throwable) { - // If we can't check, assume it's available — the actual connection - // attempt in flushRedis() will catch real failures. - return true; - } + /** + * Parse a non-negative integer Redis environment value. + */ + protected function integerRedisEnvironmentValue(string $key, mixed $value): int + { + return RedisTestDatabases::integerEnvironmentValue($key, $value); } /** diff --git a/src/foundation/src/Testing/RedisTestDatabases.php b/src/foundation/src/Testing/RedisTestDatabases.php new file mode 100644 index 000000000..f696db112 --- /dev/null +++ b/src/foundation/src/Testing/RedisTestDatabases.php @@ -0,0 +1,168 @@ + + */ + public static function workerDatabases(): array + { + $minimumDatabase = static::minimumDatabase(); + $maximumDatabase = static::maximumDatabase(); + + if ($maximumDatabase < $minimumDatabase) { + throw new RuntimeException('REDIS_TEST_DB_MAX must be greater than or equal to REDIS_TEST_DB_MIN.'); + } + + $secondaryDatabase = static::configuredSecondaryDatabase(); + $databases = range($minimumDatabase, $maximumDatabase); + + if ($secondaryDatabase !== null) { + $databases = array_values(array_filter( + $databases, + static fn (int $database): bool => $database !== $secondaryDatabase, + )); + } + + return $databases; + } + + /** + * Get the zero-based Redis worker index for a ParaTest token. + */ + public static function workerIndex(string $token): int + { + if (! ctype_digit($token) || (int) $token < 1) { + throw new RuntimeException('TEST_TOKEN must be a positive integer for Redis parallel testing.'); + } + + return (int) $token - 1; + } + + /** + * Get a non-negative integer Redis environment value. + */ + public static function integerEnvironment(string $key, int $default): int + { + $value = env($key); + + if ($value === null) { + return $default; + } + + return static::integerEnvironmentValue($key, $value); + } + + /** + * Parse a non-negative integer Redis environment value. + */ + public static function integerEnvironmentValue(string $key, mixed $value): int + { + if (is_int($value)) { + if ($value < 0) { + throw new RuntimeException("{$key} must be a non-negative integer."); + } + + return $value; + } + + if (is_string($value) && ctype_digit($value)) { + return (int) $value; + } + + throw new RuntimeException("{$key} must be a non-negative integer."); + } +} diff --git a/src/server/src/Commands/ServerStartCommand.php b/src/server/src/Commands/ServerStartCommand.php index e48ee2a25..b4c4d315a 100644 --- a/src/server/src/Commands/ServerStartCommand.php +++ b/src/server/src/Commands/ServerStartCommand.php @@ -4,17 +4,20 @@ namespace Hypervel\Server\Commands; +use Hypervel\Contracts\Config\Repository as ConfigRepository; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Log\StdoutLoggerInterface; use Hypervel\Engine\Coroutine; use Hypervel\Foundation\Application; use Hypervel\Server\ServerFactory; +use Hypervel\Server\ServerInterface; use InvalidArgumentException; use Override; use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command as SymfonyCommand; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use function Hypervel\Support\swoole_hook_flags; @@ -39,13 +42,24 @@ public function __construct(protected Container $container) #[Override] protected function execute(InputInterface $input, OutputInterface $output): int { - return $this->startServer(); + return $this->startServer($input); + } + + /** + * Configure the server start command. + */ + #[Override] + protected function configure(): void + { + $this + ->addOption('host', null, InputOption::VALUE_REQUIRED, 'The host address to serve the application on') + ->addOption('port', null, InputOption::VALUE_REQUIRED, 'The port to serve the application on'); } /** * Start the configured Swoole servers. */ - protected function startServer(): int + protected function startServer(InputInterface $input): int { if (Application::getInstance()->runningInConsole()) { throw new RuntimeException( @@ -57,11 +71,52 @@ protected function startServer(): int ->setEventDispatcher($this->container->make('events')) ->setLogger($this->container->make(StdoutLoggerInterface::class)); - $serverConfig = $this->container->make('config')->array('server', []); + /** @var ConfigRepository $config */ + $config = $this->container->make('config'); + $serverConfig = $config->array('server', []); if (! $serverConfig) { throw new InvalidArgumentException('At least one server should be defined.'); } + $host = $input->getOption('host'); + $port = $input->getOption('port'); + + if ($host !== null || $port !== null) { + if ($port !== null && filter_var($port, FILTER_VALIDATE_INT, [ + 'options' => ['min_range' => 1, 'max_range' => 65535], + ]) === false) { + throw new InvalidArgumentException('The serve port must be an integer between 1 and 65535.'); + } + + $servers = $serverConfig['servers'] ?? []; + $httpServerIndex = null; + + foreach ($servers as $index => $server) { + if (($server['type'] ?? null) === ServerInterface::SERVER_HTTP) { + $httpServerIndex = $index; + break; + } + } + + if ($httpServerIndex === null) { + throw new InvalidArgumentException('Cannot override server host or port because no HTTP server is configured.'); + } + + if ($host !== null) { + $servers[$httpServerIndex]['host'] = (string) $host; + } + + if ($port !== null) { + $servers[$httpServerIndex]['port'] = (int) $port; + } + + $serverConfig['servers'] = $servers; + + // Command options are applied before workers start so ServerFactory + // and later config readers agree on the bound HTTP address. + $config->set('server.servers', $servers); + } + $serverFactory->configure($serverConfig); Coroutine::set(['hook_flags' => swoole_hook_flags()]); diff --git a/src/testbench/README.md b/src/testbench/README.md index a812f9bc3..f5261ed12 100644 --- a/src/testbench/README.md +++ b/src/testbench/README.md @@ -1,4 +1,16 @@ Testbench for Hypervel === -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/testbench) \ No newline at end of file +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/testbench) + +Ported from: https://github.com/orchestral/testbench-core + +## Differences From Orchestra Testbench + +Hypervel does not forward the parent Testbench CLI application's full runtime environment to `package:test` subprocesses. In package-test mode, package and workbench environment files are copied into the child runtime application, while shell or CI environment variables, PHPUnit XML values, and Testbench YAML `env` values continue to reach package-test child processes through their normal channels. + +Hypervel does not use Orchestra's `TESTBENCH_APP_BASE_PATH` channel. Each process owns its runtime application identity through `BASE_PATH`, remote child processes receive the current worker clone through `TESTBENCH_BASE_PATH`, and `APP_BASE_PATH` remains the explicit user override. + +Hypervel's Testbench skeleton config files contain only intentional differences from the framework base configuration. The framework configuration is merged into the skeleton application during bootstrap. + +Hypervel includes root package discovery metadata when a `package:test` worker builds the Testbench package manifest. Orchestra's persistent skeleton can be seeded by the parent Testbench CLI process, while Hypervel's per-worker runtime skeletons may build their manifests directly inside PHPUnit / ParaTest workers. diff --git a/src/testbench/bin/testbench b/src/testbench/bin/testbench index d14449002..d35772797 100755 --- a/src/testbench/bin/testbench +++ b/src/testbench/bin/testbench @@ -19,6 +19,8 @@ foreach (array_filter($autoloadPaths) as $autoloadPath) { } } +define('TESTBENCH_CORE', true); + // Determine consumer package path $envPath = getenv('TESTBENCH_WORKING_PATH'); $installedRootPath = InstalledVersions::getRootPackage()['install_path'] ?? null; diff --git a/src/testbench/composer.json b/src/testbench/composer.json index 46dc2e8be..c584e2e45 100644 --- a/src/testbench/composer.json +++ b/src/testbench/composer.json @@ -48,7 +48,11 @@ "src/functions.php" ], "psr-4": { - "Hypervel\\Testbench\\": "src/", + "Hypervel\\Testbench\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { "Workbench\\App\\": "workbench/app/", "Workbench\\Database\\Factories\\": "workbench/database/factories/", "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" diff --git a/src/testbench/hypervel/.env.example b/src/testbench/hypervel/.env.example index 3cd5b4e9b..80ae037ce 100644 --- a/src/testbench/hypervel/.env.example +++ b/src/testbench/hypervel/.env.example @@ -27,6 +27,7 @@ DB_CONNECTION=sqlite # DB_USERNAME=root # DB_PASSWORD= +SESSION_DRIVER=cookie SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ @@ -40,6 +41,7 @@ SESSION_DOMAIN=null BROADCAST_CONNECTION=log FILESYSTEM_DISK=local +CACHE_STORE=database # CACHE_PREFIX= REDIS_HOST=127.0.0.1 diff --git a/src/testbench/hypervel/config/session.php b/src/testbench/hypervel/config/session.php index 50c0e7982..40d04c599 100644 --- a/src/testbench/hypervel/config/session.php +++ b/src/testbench/hypervel/config/session.php @@ -3,8 +3,9 @@ declare(strict_types=1); return [ - 'driver' => 'array', - 'store' => 'array', + 'driver' => env('SESSION_DRIVER', 'array'), + 'lottery' => [0, 2], + 'cookie' => 'hypervel_session', ]; diff --git a/src/testbench/src/Bootstrapper.php b/src/testbench/src/Bootstrapper.php index 514d17941..1d5ebf8b2 100644 --- a/src/testbench/src/Bootstrapper.php +++ b/src/testbench/src/Bootstrapper.php @@ -7,6 +7,9 @@ use Hypervel\Filesystem\Filesystem; use Hypervel\Testbench\Contracts\Config as ConfigContract; use Hypervel\Testbench\Foundation\Config; +use Hypervel\Testbench\Foundation\Env; +use Hypervel\Testbench\Foundation\EnvironmentFile; +use UnexpectedValueException; class Bootstrapper { @@ -39,7 +42,7 @@ public static function bootstrap(): void $sourcePath = static::$configuration['hypervel']; } - $basePath = static::resolveRuntimeBasePath($sourcePath); + $basePath = static::resolveRuntimeBasePath($sourcePath, $workingPath); ! defined('BASE_PATH') && define('BASE_PATH', $basePath); ! defined('SWOOLE_HOOK_FLAGS') && define('SWOOLE_HOOK_FLAGS', SWOOLE_HOOK_ALL); @@ -116,7 +119,7 @@ protected static function hasConfigurationFile(string $workingPath, string $file * to a temp directory and using that as BASE_PATH, the committed skeleton * stays clean. The copy is deleted on shutdown. */ - protected static function createRuntimeCopy(string $sourcePath): string + protected static function createRuntimeCopy(string $sourcePath, string $workingPath): string { $token = $_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? 'default'; $pid = getmypid(); @@ -150,11 +153,15 @@ protected static function createRuntimeCopy(string $sourcePath): string } } - $filesystem->deleteDirectory($staleDir); + static::deleteRuntimeDirectory($staleDir); } $filesystem->copyDirectory($sourcePath, $runtimePath); + if (Env::has('TESTBENCH_PACKAGE_TESTER')) { + static::copyPackageEnvironmentFile($filesystem, $runtimePath, $workingPath); + } + static::$runtimePath = $runtimePath; register_shutdown_function(static function () { @@ -164,10 +171,38 @@ protected static function createRuntimeCopy(string $sourcePath): string return $runtimePath; } + /** + * Copy the package or workbench environment file into the runtime copy. + */ + protected static function copyPackageEnvironmentFile(Filesystem $filesystem, string $runtimePath, string $workingPath): void + { + $environmentFile = (new EnvironmentFile($filesystem))->packageOrSkeletonFallback( + workingPath: $workingPath, + appBasePath: $runtimePath, + filename: static::testbenchEnvironmentFile(), + ); + + if ($environmentFile !== null) { + $filesystem->copy($environmentFile, join_paths($runtimePath, '.env')); + } + } + + /** + * Determine the active Testbench environment file name. + */ + protected static function testbenchEnvironmentFile(): string + { + $environmentFile = Env::get('TESTBENCH_ENVIRONMENT_FILENAME', '.env'); + + return is_string($environmentFile) && $environmentFile !== '' + ? $environmentFile + : '.env'; + } + /** * Resolve the runtime base path for the current process. */ - protected static function resolveRuntimeBasePath(string $sourcePath): string + protected static function resolveRuntimeBasePath(string $sourcePath, string $workingPath): string { $existingRuntimePath = $_SERVER['TESTBENCH_BASE_PATH'] ?? $_ENV['TESTBENCH_BASE_PATH'] ?? null; $isRemoteProcess = ($_SERVER['TESTBENCH_PACKAGE_REMOTE'] ?? $_ENV['TESTBENCH_PACKAGE_REMOTE'] ?? null) === '(true)'; @@ -176,7 +211,7 @@ protected static function resolveRuntimeBasePath(string $sourcePath): string return $existingRuntimePath; } - return static::createRuntimeCopy($sourcePath); + return static::createRuntimeCopy($sourcePath, $workingPath); } /** @@ -188,13 +223,58 @@ protected static function deleteRuntimeCopy(): void return; } + static::deleteRuntimeDirectory(static::$runtimePath); + + static::$runtimePath = null; + } + + /** + * Delete a runtime copy while tolerating sibling cleanup races. + * + * Multiple same-token Testbench children can bootstrap at once and purge + * the same stale runtime copy. If another child wins the race, the missing + * directory is the desired postcondition; if the directory remains, the + * original filesystem failure is still surfaced. + */ + protected static function deleteRuntimeDirectory(string $directory): void + { $filesystem = static::getFilesystem(); - if ($filesystem->isDirectory(static::$runtimePath)) { - $filesystem->deleteDirectory(static::$runtimePath); + if (! static::runtimeDirectoryExists($filesystem, $directory)) { + return; } - static::$runtimePath = null; + try { + $filesystem->deleteDirectory($directory); + + return; + } catch (UnexpectedValueException) { + clearstatcache(true, $directory); + + if (! static::runtimeDirectoryExists($filesystem, $directory)) { + return; + } + } + + try { + $filesystem->deleteDirectory($directory); + } catch (UnexpectedValueException $retryException) { + clearstatcache(true, $directory); + + if (static::runtimeDirectoryExists($filesystem, $directory)) { + throw $retryException; + } + } + } + + /** + * Determine if a runtime directory exists. + * + * @phpstan-impure + */ + protected static function runtimeDirectoryExists(Filesystem $filesystem, string $directory): bool + { + return $filesystem->isDirectory($directory); } /** diff --git a/src/testbench/src/Concerns/CreatesApplication.php b/src/testbench/src/Concerns/CreatesApplication.php index 1d13d572a..5f7bd2cb6 100644 --- a/src/testbench/src/Concerns/CreatesApplication.php +++ b/src/testbench/src/Concerns/CreatesApplication.php @@ -573,7 +573,7 @@ protected function registerPackageAliases(ApplicationContract $app): void */ protected function configureParallelCachePaths(): void { - $token = env('TEST_TOKEN'); + $token = $this->paraTestWorkerToken(); if ($token === null) { return; @@ -581,4 +581,22 @@ protected function configureParallelCachePaths(): void $_SERVER['APP_ROUTES_CACHE'] = "cache/routes-v7-test-{$token}.php"; } + + /** + * Get the current ParaTest worker token before the application exists. + * + * This reads the raw runtime arrays because cached route setup may run + * before parent::setUp(), when no application or ParallelTesting service + * has been created yet. + */ + protected function paraTestWorkerToken(): ?string + { + $token = $_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? null; + + if (! is_string($token) || $token === '') { + return null; + } + + return preg_replace('/[^A-Za-z0-9_.-]/', '_', $token); + } } diff --git a/src/testbench/src/Concerns/HandlesRoutes.php b/src/testbench/src/Concerns/HandlesRoutes.php index bb517b07f..34a815edc 100644 --- a/src/testbench/src/Concerns/HandlesRoutes.php +++ b/src/testbench/src/Concerns/HandlesRoutes.php @@ -33,6 +33,13 @@ trait HandlesRoutes */ protected bool $requireApplicationCachedRoutesHasRun = false; + /** + * Route files written by this test instance. + * + * @var array + */ + protected array $testbenchRouteFiles = []; + /** * Setup application routes. */ @@ -94,6 +101,8 @@ protected function defineStashRoutes(Closure|string $route): void */ protected function defineCacheRoutes(Closure|string $route, bool $cached = true): void { + $this->configureParallelCachePaths(); + static::usesTestingFeature($attribute = new UsesVendor, Attribute::TARGET_METHOD); if ( @@ -106,8 +115,6 @@ protected function defineCacheRoutes(Closure|string $route, bool $cached = true) $files = new Filesystem; - $time = time(); - $basePath = static::applicationBasePath(); if ($route instanceof Closure) { $cached = false; @@ -117,10 +124,10 @@ protected function defineCacheRoutes(Closure|string $route, bool $cached = true) $route = str_replace('{{routes}}', var_export($serializeRoute, true), $stub); } - $files->put( - join_paths($basePath, 'routes', "testbench-{$time}.php"), - $route - ); + $routeFile = $this->testbenchRouteFilePath($basePath); + $this->testbenchRouteFiles[] = $routeFile; + + $files->put($routeFile, $route); if ($cached === true) { remote('route:cache')->mustRun(); @@ -178,11 +185,25 @@ protected function requireApplicationCachedRoutes(Filesystem $files, bool $cache // so hardcoding routes-v7.php would miss the actual file and leak stale caches. $files->delete( $this->app->getCachedRoutesPath(), - ...$files->glob($this->app->basePath(join_paths('routes', 'testbench-*.php'))) + ...$this->testbenchRouteFiles ); } }); $this->requireApplicationCachedRoutesHasRun = true; } + + /** + * Get a route file path owned by this test instance. + */ + protected function testbenchRouteFilePath(string $basePath): string + { + $token = $this->paraTestWorkerToken() ?? 'default'; + + return join_paths( + $basePath, + 'routes', + sprintf('testbench-%s-%s-%s.php', $token, getmypid(), hrtime(true)) + ); + } } diff --git a/src/testbench/src/Concerns/InteractsWithWorkbench.php b/src/testbench/src/Concerns/InteractsWithWorkbench.php index 577ea27a6..9e74e20b4 100644 --- a/src/testbench/src/Concerns/InteractsWithWorkbench.php +++ b/src/testbench/src/Concerns/InteractsWithWorkbench.php @@ -24,7 +24,7 @@ trait InteractsWithWorkbench */ public static function applicationBasePathUsingWorkbench(): ?string { - return $_ENV['APP_BASE_PATH'] ?? $_ENV['TESTBENCH_APP_BASE_PATH'] ?? null; + return $_ENV['APP_BASE_PATH'] ?? null; } /** @@ -142,35 +142,8 @@ public static function cachedConfigurationForWorkbench(): ?ConfigContract return Workbench::configuration(); } - /** - * Prepare the testing environment before the running the test case. - * - * @internal - * - * @codeCoverageIgnore - */ - public static function setUpBeforeClassUsingWorkbench(): void - { - $config = static::cachedConfigurationForWorkbench(); - - if ( - $config instanceof ConfigContract - && is_string($config['hypervel'] ?? null) - && static::usesTestingConcern(WithWorkbench::class) - ) { - $_ENV['TESTBENCH_APP_BASE_PATH'] = $config['hypervel']; - } - } - - /** - * Clean up the testing environment before the next test case. - * - * @internal - * - * @codeCoverageIgnore - */ - public static function tearDownAfterClassUsingWorkbench(): void - { - unset($_ENV['TESTBENCH_APP_BASE_PATH']); - } + // Orchestra sets a per-class app-base env pointer for YAML custom skeletons. + // Hypervel's Bootstrapper already clones the yaml skeleton as each process's + // runtime identity, and per-class skeleton switching cannot work with + // process-owned disposable clones. } diff --git a/src/testbench/src/Features/ParallelRunner.php b/src/testbench/src/Features/ParallelRunner.php index e4caee14a..65be5cbf2 100644 --- a/src/testbench/src/Features/ParallelRunner.php +++ b/src/testbench/src/Features/ParallelRunner.php @@ -24,10 +24,6 @@ protected function createApplication(): ApplicationContract define('TESTBENCH_WORKING_PATH', Env::get('TESTBENCH_WORKING_PATH')); } - if (! isset($_ENV['TESTBENCH_APP_BASE_PATH'])) { - $_ENV['TESTBENCH_APP_BASE_PATH'] = Env::get('TESTBENCH_APP_BASE_PATH'); - } - $applicationResolver = static::$applicationResolver ?: static fn () => container()->createApplication(); return $applicationResolver(); diff --git a/src/testbench/src/Foundation/Bootstrap/SyncTestbenchCachedRoutes.php b/src/testbench/src/Foundation/Bootstrap/SyncTestbenchCachedRoutes.php index 69a9771d3..6676b0969 100644 --- a/src/testbench/src/Foundation/Bootstrap/SyncTestbenchCachedRoutes.php +++ b/src/testbench/src/Foundation/Bootstrap/SyncTestbenchCachedRoutes.php @@ -18,9 +18,10 @@ public function bootstrap(Application $app): void { /** @var \Hypervel\Routing\Router $router */ $router = $app->make('router'); + $routeFiles = glob($app->basePath(join_paths('routes', 'testbench-*.php'))) ?: []; /* @phpstan-ignore argument.type */ - (new Collection(glob($app->basePath(join_paths('routes', 'testbench-*.php'))))) + (new Collection($routeFiles)) ->each(static function ($routeFile) use ($app, $router) { // @phpstan-ignore closure.unusedUse, closure.unusedUse require $routeFile; }); diff --git a/src/testbench/src/Foundation/Console/Concerns/CopyTestbenchFiles.php b/src/testbench/src/Foundation/Console/Concerns/CopyTestbenchFiles.php index a1c30a398..0aea6ceb0 100644 --- a/src/testbench/src/Foundation/Console/Concerns/CopyTestbenchFiles.php +++ b/src/testbench/src/Foundation/Console/Concerns/CopyTestbenchFiles.php @@ -9,9 +9,9 @@ use Hypervel\Support\LazyCollection; use Hypervel\Testbench\Foundation\Console\TerminatingConsole; use Hypervel\Testbench\Foundation\Env; +use Hypervel\Testbench\Foundation\EnvironmentFile; use function Hypervel\Testbench\join_paths; -use function Hypervel\Testbench\testbench_path; trait CopyTestbenchFiles { @@ -27,7 +27,7 @@ protected function copyTestbenchConfigurationFile( bool $backupExistingFile = true, bool $resetOnTerminating = true ): void { - $sourcePath = $this->resolveTestbenchSourcePath($filesystem, $workingPath); + $sourcePath = (new EnvironmentFile($filesystem))->sourcePath($workingPath); $configurationFile = (new LazyCollection(static function () { yield 'testbench.yaml'; @@ -72,31 +72,11 @@ protected function copyTestbenchDotEnvFile( bool $backupExistingFile = true, bool $resetOnTerminating = true ): void { - $sourcePath = $this->resolveTestbenchSourcePath($filesystem, $workingPath); - $workingPath = $filesystem->isDirectory(join_paths($sourcePath, 'workbench')) - ? join_paths($sourcePath, 'workbench') - : $sourcePath; - - $testbenchEnvFilename = $this->testbenchEnvironmentFile(); - - $configurationFile = (new LazyCollection(static function () use ($testbenchEnvFilename) { - $defaultTestbenchEnvFilename = '.env'; - - yield $testbenchEnvFilename; - yield "{$testbenchEnvFilename}.example"; - yield "{$testbenchEnvFilename}.dist"; - - yield $defaultTestbenchEnvFilename; - yield "{$defaultTestbenchEnvFilename}.example"; - yield "{$defaultTestbenchEnvFilename}.dist"; - }))->unique() - ->map(static fn ($file) => join_paths($workingPath, $file)) - ->filter(static fn ($file) => $filesystem->isFile($file)) - ->first(); - - if ($configurationFile === null && $filesystem->isFile($app->basePath('.env.example'))) { - $configurationFile = $app->basePath('.env.example'); - } + $configurationFile = (new EnvironmentFile($filesystem))->packageOrSkeletonFallback( + workingPath: $workingPath, + appBasePath: $app->basePath(), + filename: $this->testbenchEnvironmentFile(), + ); $environmentFile = $app->basePath('.env'); @@ -130,22 +110,4 @@ protected function testbenchEnvironmentFile(): string default => '.env', }; } - - /** - * Resolve the source path for testbench config and workbench fixtures. - */ - protected function resolveTestbenchSourcePath(Filesystem $filesystem, string $workingPath): string - { - foreach (['testbench.yaml', 'testbench.yaml.example', 'testbench.yaml.dist'] as $configurationFile) { - if ($filesystem->isFile(join_paths($workingPath, $configurationFile))) { - return $workingPath; - } - } - - if ($filesystem->isDirectory(join_paths($workingPath, 'workbench'))) { - return $workingPath; - } - - return testbench_path(); - } } diff --git a/src/testbench/src/Foundation/Console/ServeCommand.php b/src/testbench/src/Foundation/Console/ServeCommand.php index c5a45314e..7aad57a70 100644 --- a/src/testbench/src/Foundation/Console/ServeCommand.php +++ b/src/testbench/src/Foundation/Console/ServeCommand.php @@ -45,7 +45,7 @@ class_exists(ComposerConfig::class, false) event(new ServeCommandStarted($input, $styledOutput, $components)); try { - $exitCode = $this->startServer(); + $exitCode = $this->startServer($input); } catch (Throwable $throwable) { event(new ServeCommandEnded($input, $styledOutput, $components, self::FAILURE)); diff --git a/src/testbench/src/Foundation/Console/TestCommand.php b/src/testbench/src/Foundation/Console/TestCommand.php index b4afff3cf..cea567728 100644 --- a/src/testbench/src/Foundation/Console/TestCommand.php +++ b/src/testbench/src/Foundation/Console/TestCommand.php @@ -4,13 +4,15 @@ namespace Hypervel\Testbench\Foundation\Console; +use Dotenv\Parser\Parser; +use Dotenv\Store\StringStore; use Hypervel\Support\Collection; +use Hypervel\Testbench\Bootstrapper; use Hypervel\Testbench\Features\ParallelRunner; use Hypervel\Testing\Console\TestCommandBase; use Override; use Symfony\Component\Console\Attribute\AsCommand; -use function Hypervel\Testbench\defined_environment_variables; use function Hypervel\Testbench\is_testbench_cli; use function Hypervel\Testbench\package_path; @@ -57,13 +59,38 @@ public function configure(): void #[Override] protected function baseEnvironmentVariables(): Collection { - return (new Collection(defined_environment_variables()))->merge(parent::baseEnvironmentVariables())->merge([ + return (new Collection($this->configurationEnvironmentVariables()))->merge(parent::baseEnvironmentVariables())->merge([ 'TESTBENCH_PACKAGE_TESTER' => '(true)', 'TESTBENCH_WORKING_PATH' => package_path(), - 'TESTBENCH_APP_BASE_PATH' => $this->hypervel->basePath(), ]); } + /** + * Get configured Testbench environment variables. + * + * @return array + */ + protected function configurationEnvironmentVariables(): array + { + $environmentVariables = Bootstrapper::getConfig()['env'] ?? []; + + if (! is_array($environmentVariables) || $environmentVariables === []) { + return []; + } + + $store = new StringStore(implode(PHP_EOL, $environmentVariables)); + $parser = new Parser; + $variables = []; + + foreach ($parser->parse($store->read()) as $entry) { + if ($entry->getValue()->isDefined()) { + $variables[$entry->getName()] = $entry->getValue()->get()->getChars(); + } + } + + return $variables; + } + /** * Get the parallel runner class. * diff --git a/src/testbench/src/Foundation/EnvironmentFile.php b/src/testbench/src/Foundation/EnvironmentFile.php new file mode 100644 index 000000000..25ce0e30c --- /dev/null +++ b/src/testbench/src/Foundation/EnvironmentFile.php @@ -0,0 +1,106 @@ +sourcePath($workingPath); + $environmentPath = $this->filesystem->isDirectory(join_paths($sourcePath, 'workbench')) + ? join_paths($sourcePath, 'workbench') + : $sourcePath; + + return $this->firstExisting($environmentPath, $this->candidateNames($filename)); + } + + /** + * Resolve the package or workbench environment file, falling back to the skeleton example. + */ + public function packageOrSkeletonFallback(string $workingPath, string $appBasePath, string $filename = '.env'): ?string + { + return $this->package($workingPath, $filename) + ?? $this->skeletonFallback($appBasePath); + } + + /** + * Resolve the source path for testbench config and workbench fixtures. + */ + public function sourcePath(string $workingPath): string + { + foreach (['testbench.yaml', 'testbench.yaml.example', 'testbench.yaml.dist'] as $configurationFile) { + if ($this->filesystem->isFile(join_paths($workingPath, $configurationFile))) { + return $workingPath; + } + } + + if ($this->filesystem->isDirectory(join_paths($workingPath, 'workbench'))) { + return $workingPath; + } + + return testbench_path(); + } + + /** + * Resolve the first existing file from the ordered candidate list. + * + * @param array $candidates + */ + protected function firstExisting(string $path, array $candidates): ?string + { + foreach ($candidates as $candidate) { + $file = join_paths($path, $candidate); + + if ($this->filesystem->isFile($file)) { + return $file; + } + } + + return null; + } + + /** + * Get the ordered environment file candidate names. + * + * @return array + */ + protected function candidateNames(string $filename): array + { + return array_values(array_unique([ + $filename, + "{$filename}.example", + "{$filename}.dist", + '.env', + '.env.example', + '.env.dist', + ])); + } + + /** + * Resolve the skeleton environment fallback. + */ + protected function skeletonFallback(string $appBasePath): ?string + { + $file = join_paths($appBasePath, '.env.example'); + + return $this->filesystem->isFile($file) ? $file : null; + } +} diff --git a/src/testbench/src/Foundation/PackageManifest.php b/src/testbench/src/Foundation/PackageManifest.php index eeed76050..945504dc7 100644 --- a/src/testbench/src/Foundation/PackageManifest.php +++ b/src/testbench/src/Foundation/PackageManifest.php @@ -150,7 +150,11 @@ protected function providersFromRoot(): array */ protected function providersFromTestbench(): ?array { - if (is_testbench_cli() && is_file($composerFile = package_path('composer.json'))) { + // PHPUnit workers are not Testbench CLI processes, but package:test + // workers still need the package root metadata when they build a fresh + // clone manifest. + if ((is_testbench_cli() || Env::has('TESTBENCH_PACKAGE_TESTER')) + && is_file($composerFile = package_path('composer.json'))) { return $this->files->json($composerFile); } diff --git a/src/testbench/src/TestCase.php b/src/testbench/src/TestCase.php index 4a916e48d..91a1c8715 100644 --- a/src/testbench/src/TestCase.php +++ b/src/testbench/src/TestCase.php @@ -138,7 +138,6 @@ public static function setUpBeforeClass(): void } static::setUpBeforeClassUsingTestCase(); - static::setUpBeforeClassUsingWorkbench(); } /** @@ -146,7 +145,6 @@ public static function setUpBeforeClass(): void */ public static function tearDownAfterClass(): void { - static::tearDownAfterClassUsingWorkbench(); static::tearDownAfterClassUsingTestCase(); /* @phpstan-ignore class.notFound */ diff --git a/src/testbench/testbench.yaml b/src/testbench/testbench.yaml index 2d490d98d..9099526e1 100644 --- a/src/testbench/testbench.yaml +++ b/src/testbench/testbench.yaml @@ -1,6 +1,9 @@ providers: - Workbench\App\Providers\WorkbenchServiceProvider +dont-discover: + - hypervel/components + env: APP_NAME: "Testbench" diff --git a/src/testing/src/Concerns/RunsInParallel.php b/src/testing/src/Concerns/RunsInParallel.php index 280b7d2b7..6b3a0e8dc 100644 --- a/src/testing/src/Concerns/RunsInParallel.php +++ b/src/testing/src/Concerns/RunsInParallel.php @@ -110,12 +110,17 @@ public function execute(): int */ protected function forEachProcess(callable $callback): void { - Collection::range(1, $this->options->processes)->each(function ($token) use ($callback) { - tap($this->createApplication(), function ($app) use ($callback, $token) { - ParallelTesting::resolveTokenUsing(fn () => $token); + Collection::range(1, $this->options->processes)->each(function ($token) use ($callback): void { + $application = $this->createApplication(); - $callback($app); - })->flush(); + try { + ParallelTesting::resolveTokenUsing(fn () => (string) $token); + + $callback($application); + } finally { + ParallelTesting::resolveTokenUsing(null); + $application->flush(); + } }); } diff --git a/src/testing/src/Console/TestCommandBase.php b/src/testing/src/Console/TestCommandBase.php index eafbad8b9..7f9ffd091 100644 --- a/src/testing/src/Console/TestCommandBase.php +++ b/src/testing/src/Console/TestCommandBase.php @@ -396,11 +396,11 @@ protected function clearEnv(): void } $variables = self::getEnvironmentVariables($path, $this->hypervel->environmentFile()); - $repository = Env::getRepository(); - foreach ($variables as $name) { - $repository->clear($name); - } + // The immutable dotenv writer refuses to clear keys it did not load, so + // delete directly from every adapter after rebuilding the adapter list. + Env::getRepository(); + Env::deleteMany($variables); } /** diff --git a/tests/Di/Bootstrap/GenerateProxiesTest.php b/tests/Di/Bootstrap/GenerateProxiesTest.php index 7b71a64f1..1792a709c 100644 --- a/tests/Di/Bootstrap/GenerateProxiesTest.php +++ b/tests/Di/Bootstrap/GenerateProxiesTest.php @@ -5,10 +5,13 @@ namespace Hypervel\Tests\Di\Bootstrap; use Composer\Autoload\ClassLoader; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Di\Aop\AspectCollector; use Hypervel\Di\Aop\AstVisitorRegistry; use Hypervel\Di\Aop\ProxyCallVisitor; +use Hypervel\Di\Aop\ProxyManager; use Hypervel\Di\Bootstrap\GenerateProxies; +use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Composer; use Hypervel\Tests\TestCase; use Mockery as m; @@ -18,13 +21,23 @@ class GenerateProxiesTest extends TestCase { private ?ClassLoader $originalLoader = null; + private ?ClassLoader $registeredLoader = null; + protected function tearDown(): void { + if ($this->registeredLoader !== null) { + $this->registeredLoader->unregister(); + $this->registeredLoader = null; + } + if ($this->originalLoader !== null) { Composer::setLoader($this->originalLoader); $this->originalLoader = null; } + GenerateProxies::flushState(); + ProxyManager::flushState(); + parent::tearDown(); } @@ -65,7 +78,7 @@ public function testBuildClassMapResolvesPsr4ClassesViaFindFile() $loader = new ClassLoader; // No class map entries — simulates a non-optimized autoloader $loader->addPsr4('Hypervel\Support\\', [__DIR__ . '/../../../src/support/src/']); - $loader->register(); + $this->registerLoader($loader); $this->originalLoader = Composer::setLoader($loader); @@ -189,4 +202,197 @@ public function testDoesNotRegisterProxyCallVisitorTwice() $this->assertSame(1, $count); } + + public function testRegeneratesDeletedProxyFilesFromTheCapturedSourceMap(): void + { + $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir, ClassLoader $loader): void { + AspectCollector::setAround('TestAspect', [$className . '::value']); + + $this->bootstrapProxies($proxyDir); + + $proxyFile = $loader->getClassMap()[$className]; + $this->assertFileExists($proxyFile); + + (new Filesystem)->deleteDirectory($proxyDir); + + $this->bootstrapProxies($proxyDir); + + $this->assertFileExists($proxyFile); + $this->assertStringContainsString('original-source', (string) file_get_contents($proxyFile)); + }); + } + + public function testSkipsProxyPathsReturnedByFindFileAfterTheSourceMapIsFlushed(): void + { + $this->withPsr4ProxyFixture(function (string $className, string $proxyDir, ClassLoader $loader): void { + AspectCollector::setAround('TestAspect', [$className . '::value']); + + $this->bootstrapProxies($proxyDir); + + $proxyFile = $loader->getClassMap()[$className]; + $this->assertFileExists($proxyFile); + + GenerateProxies::flushState(); + ProxyManager::flushState(); + + $bootstrapper = new GenerateProxies; + $reflection = new ReflectionMethod($bootstrapper, 'buildClassMap'); + + $classMap = $reflection->invoke($bootstrapper); + + $this->assertArrayNotHasKey($className, $classMap); + + (new Filesystem)->deleteDirectory($proxyDir); + + $this->bootstrapProxies($proxyDir); + + $this->assertFileDoesNotExist($proxyFile); + }); + } + + public function testRegeneratesProxyFilesWhenTheSourceFileIsNewer(): void + { + $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir, ClassLoader $loader): void { + AspectCollector::setAround('TestAspect', [$className . '::value']); + + $this->bootstrapProxies($proxyDir); + + $proxyFile = $loader->getClassMap()[$className]; + $this->writeProxySource($sourceFile, $className, 'newer-source'); + touch($sourceFile, filemtime($proxyFile) + 2); + + $this->bootstrapProxies($proxyDir); + + $this->assertStringContainsString('newer-source', (string) file_get_contents($proxyFile)); + }); + } + + public function testRegeneratesProxyFilesWhenTheSourcePathChanges(): void + { + $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir, ClassLoader $loader): void { + AspectCollector::setAround('TestAspect', [$className . '::value']); + + $this->bootstrapProxies($proxyDir); + + $proxyFile = $loader->getClassMap()[$className]; + $this->writeProxySource($overrideFile, $className, 'override-source'); + touch($overrideFile, filemtime($proxyFile) - 100); + + $loader->addClassMap([$className => $overrideFile]); + + $this->bootstrapProxies($proxyDir); + + $this->assertStringContainsString('override-source', (string) file_get_contents($proxyFile)); + }); + } + + /** + * Run a callback with a controlled proxy fixture. + */ + private function withProxyFixture(callable $callback): void + { + $filesystem = new Filesystem; + $directory = sys_get_temp_dir() . '/hypervel-test-aop-' . getmypid() . '-' . bin2hex(random_bytes(6)); + $sourceDirectory = $directory . '/src'; + $proxyDir = $directory . '/aop/'; + $className = 'Hypervel\Tests\Di\Bootstrap\Fixtures\AopProxySource' . bin2hex(random_bytes(4)); + $sourceFile = $sourceDirectory . '/AopProxySource.php'; + $overrideFile = $sourceDirectory . '/AopProxySourceOverride.php'; + + $filesystem->ensureDirectoryExists($sourceDirectory); + $this->writeProxySource($sourceFile, $className, 'original-source'); + + $loader = new ClassLoader; + $loader->addClassMap([$className => $sourceFile]); + $this->registerLoader($loader); + + $this->originalLoader = Composer::setLoader($loader); + GenerateProxies::flushState(); + ProxyManager::flushState(); + + try { + $callback($className, $sourceFile, $overrideFile, $proxyDir, $loader); + } finally { + $filesystem->deleteDirectory($directory); + } + } + + /** + * Run a callback with a PSR-4-only proxy fixture. + */ + private function withPsr4ProxyFixture(callable $callback): void + { + $filesystem = new Filesystem; + $directory = sys_get_temp_dir() . '/hypervel-test-aop-' . getmypid() . '-' . bin2hex(random_bytes(6)); + $sourceDirectory = $directory . '/src/'; + $proxyDir = $directory . '/aop/'; + $shortName = 'AopProxyPsr4Source' . bin2hex(random_bytes(4)); + $className = 'Hypervel\Tests\Di\Bootstrap\Fixtures\\' . $shortName; + $sourceFile = $sourceDirectory . $shortName . '.php'; + + $filesystem->ensureDirectoryExists($sourceDirectory); + $this->writeProxySource($sourceFile, $className, 'psr4-source'); + + $loader = new ClassLoader; + $loader->addPsr4('Hypervel\Tests\Di\Bootstrap\Fixtures\\', [$sourceDirectory]); + $this->registerLoader($loader); + + $this->originalLoader = Composer::setLoader($loader); + GenerateProxies::flushState(); + ProxyManager::flushState(); + + try { + $callback($className, $proxyDir, $loader); + } finally { + $filesystem->deleteDirectory($directory); + } + } + + /** + * Bootstrap proxy generation against the given proxy directory. + */ + private function bootstrapProxies(string $proxyDir): void + { + $app = m::mock(ApplicationContract::class); + $app->shouldReceive('storagePath') + ->with('framework/aop/') + ->andReturn($proxyDir); + + (new GenerateProxies)->bootstrap($app); + } + + /** + * Register a controlled Composer loader. + */ + private function registerLoader(ClassLoader $loader): void + { + $loader->register(); + $this->registeredLoader = $loader; + } + + /** + * Write a source file used for proxy generation. + */ + private function writeProxySource(string $file, string $className, string $marker): void + { + $lastSeparator = strrpos($className, '\\'); + $namespace = substr($className, 0, $lastSeparator); + $shortName = substr($className, $lastSeparator + 1); + + file_put_contents($file, << + */ + private const REDIS_ENVIRONMENT_KEYS = [ + 'REDIS_DB', + 'REDIS_TEST_DB_MIN', + 'REDIS_TEST_DB_MAX', + 'REDIS_TEST_SECONDARY_DB', + ]; + + /** + * Original environment values. + * + * @var array + */ + private array $originalEnvironmentValues = []; + + protected function setUp(): void + { + parent::setUp(); + + $this->captureEnvironmentValues(); + $this->setParallelTestingToken(false); + } + + protected function tearDown(): void + { + $this->app->make(ParallelTesting::class)->resolveTokenUsing(null); + $this->restoreEnvironmentValues(); + + parent::tearDown(); + } + + public function testGetBaseRedisDbReturnsRedisDb(): void + { + $this->setRedisEnvironmentValue('REDIS_DB', '3'); + + $this->assertSame(3, $this->harness()->baseRedisDb()); + } + + public function testGetBaseRedisDbDefaultsToZero(): void + { + $this->setRedisEnvironmentValue('REDIS_DB', null); + + $this->assertSame(0, $this->harness()->baseRedisDb()); + } + + public function testGetParallelRedisDbReturnsBaseWhenNotParallel(): void + { + $this->setRedisEnvironmentValue('REDIS_DB', '4'); + + $this->assertSame(4, $this->harness()->parallelRedisDb()); + } + + public function testParallelWorkerUsesConfiguredMinimumRange(): void + { + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MIN', '4'); + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MAX', '8'); + $this->setParallelTestingToken('3'); + + $this->assertSame(6, $this->harness()->parallelRedisDb()); + } + + public function testRedisTestDbMinDefaultsToRedisDb(): void + { + $this->setRedisEnvironmentValue('REDIS_DB', '4'); + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MIN', null); + $this->setParallelTestingToken('2'); + + $this->assertSame(5, $this->harness()->parallelRedisDb()); + } + + public function testRedisTestDbMaxDefaultsToFifteen(): void + { + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MIN', '14'); + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MAX', null); + $this->setRedisEnvironmentValue('REDIS_TEST_SECONDARY_DB', null); + + $this->assertSame([14, 15], $this->harness()->workerDatabases()); + } + + public function testParallelWorkerOverflowFails(): void + { + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MIN', '1'); + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MAX', '2'); + $this->setParallelTestingToken('3'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Parallel Redis worker [3] has no configured Redis database.'); + + $this->harness()->parallelRedisDb(); + } + + public function testSecondaryRedisDbIsRequired(): void + { + $this->setRedisEnvironmentValue('REDIS_TEST_SECONDARY_DB', null); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('REDIS_TEST_SECONDARY_DB must be set before requesting the secondary Redis test database.'); + + $this->harness()->secondaryRedisDb(); + } + + public function testConfiguredSecondaryRedisDbIsReturned(): void + { + $this->setRedisEnvironmentValue('REDIS_DB', '1'); + $this->setRedisEnvironmentValue('REDIS_TEST_SECONDARY_DB', '9'); + + $this->assertSame(9, $this->harness()->secondaryRedisDb()); + } + + public function testConfiguredSecondaryRedisDbIsSkippedDuringWorkerAllocation(): void + { + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MIN', '1'); + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MAX', '4'); + $this->setRedisEnvironmentValue('REDIS_TEST_SECONDARY_DB', '2'); + + $this->setParallelTestingToken('1'); + $this->assertSame(1, $this->harness()->parallelRedisDb()); + + $this->setParallelTestingToken('2'); + $this->assertSame(3, $this->harness()->parallelRedisDb()); + + $this->setParallelTestingToken('3'); + $this->assertSame(4, $this->harness()->parallelRedisDb()); + } + + public function testSecondaryRedisDbMatchingPrimaryFails(): void + { + $this->setRedisEnvironmentValue('REDIS_DB', '2'); + $this->setRedisEnvironmentValue('REDIS_TEST_SECONDARY_DB', '2'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('REDIS_TEST_SECONDARY_DB must be different from the current Redis test database.'); - public function testGetBaseRedisDbReturnsEnvValue() + $this->harness()->secondaryRedisDb(); + } + + public function testInvalidRedisTestDbRangeFails(): void { - // Default matches database.php: env('REDIS_DB', 0) - $this->assertSame((int) env('REDIS_DB', 0), $this->getBaseRedisDb()); + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MIN', '5'); + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MAX', '4'); + $this->setParallelTestingToken('1'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('REDIS_TEST_DB_MAX must be greater than or equal to REDIS_TEST_DB_MIN.'); + + $this->harness()->parallelRedisDb(); } - public function testGetParallelRedisDbReturnsBaseWhenNoToken() + public function testInvalidRedisEnvironmentValueFails(): void { - // Without TEST_TOKEN, should return the base DB - if (env('TEST_TOKEN') !== null) { - $this->markTestSkipped('Cannot test sequential behavior when TEST_TOKEN is set'); + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MIN', 'not-a-number'); + $this->setParallelTestingToken('1'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('REDIS_TEST_DB_MIN must be a non-negative integer.'); + + $this->harness()->parallelRedisDb(); + } + + public function testInvalidNegativeRedisEnvironmentValueFails(): void + { + $this->setRedisEnvironmentValue('REDIS_DB', '-1'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('REDIS_DB must be a non-negative integer.'); + + $this->harness()->baseRedisDb(); + } + + public function testInvalidTestTokenFails(): void + { + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MIN', '1'); + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MAX', '4'); + $this->setParallelTestingToken('zero'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('TEST_TOKEN must be a positive integer for Redis parallel testing.'); + + $this->harness()->parallelRedisDb(); + } + + public function testCustomParallelTestingTokenResolverIsHonored(): void + { + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MIN', '1'); + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MAX', '5'); + $previousProcessToken = getenv('TEST_TOKEN'); + $previousServerTokenExists = array_key_exists('TEST_TOKEN', $_SERVER); + $previousServerToken = $_SERVER['TEST_TOKEN'] ?? null; + $previousEnvironmentTokenExists = array_key_exists('TEST_TOKEN', $_ENV); + $previousEnvironmentToken = $_ENV['TEST_TOKEN'] ?? null; + + try { + putenv('TEST_TOKEN=5'); + $_SERVER['TEST_TOKEN'] = '5'; + $_ENV['TEST_TOKEN'] = '5'; + $this->setParallelTestingToken('3'); + + $this->assertSame(3, $this->harness()->parallelRedisDb()); + } finally { + $previousProcessToken === false + ? putenv('TEST_TOKEN') + : putenv("TEST_TOKEN={$previousProcessToken}"); + + if ($previousServerTokenExists) { + $_SERVER['TEST_TOKEN'] = $previousServerToken; + } else { + unset($_SERVER['TEST_TOKEN']); + } + + if ($previousEnvironmentTokenExists) { + $_ENV['TEST_TOKEN'] = $previousEnvironmentToken; + } else { + unset($_ENV['TEST_TOKEN']); + } } + } + + public function testParallelTestingTokenCanBeResolvedBeforeTheTestCaseAppIsAssigned(): void + { + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MIN', '1'); + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MAX', '5'); + $this->setParallelTestingToken('4'); - $this->assertSame($this->getBaseRedisDb(), $this->getParallelRedisDb()); + $previousContainer = Container::getInstance(); + + try { + Container::setInstance($this->app); + + $this->assertSame(4, (new InteractsWithRedisHarness)->parallelRedisDb()); + } finally { + Container::setInstance($previousContainer); + } } - public function testGetSecondaryRedisDbDiffersFromPrimaryInSequentialMode() + /** + * Get an InteractsWithRedis harness. + */ + private function harness(): InteractsWithRedisHarness { - if (env('TEST_TOKEN') !== null) { - $this->markTestSkipped('Cannot test sequential behavior when TEST_TOKEN is set'); + return new InteractsWithRedisHarness($this->app); + } + + /** + * Set the parallel testing token resolver. + */ + private function setParallelTestingToken(string|false $token): void + { + $this->app->make(ParallelTesting::class)->resolveTokenUsing(fn () => $token); + } + + /** + * Capture original environment values. + */ + private function captureEnvironmentValues(): void + { + foreach (self::REDIS_ENVIRONMENT_KEYS as $key) { + $this->originalEnvironmentValues[$key] = [ + 'process' => getenv($key), + 'server_exists' => array_key_exists($key, $_SERVER), + 'server' => $_SERVER[$key] ?? null, + 'environment_exists' => array_key_exists($key, $_ENV), + 'environment' => $_ENV[$key] ?? null, + ]; } + } - $primary = $this->getParallelRedisDb(); - $secondary = $this->getSecondaryRedisDb(); + /** + * Set a Redis environment value. + */ + private function setRedisEnvironmentValue(string $key, ?string $value): void + { + if ($value === null) { + putenv($key); + unset($_SERVER[$key], $_ENV[$key]); + } else { + putenv("{$key}={$value}"); + $_SERVER[$key] = $value; + $_ENV[$key] = $value; + } - $this->assertNotSame($primary, $secondary); + Env::flushRepository(); } - public function testGetSecondaryRedisDbIsBasePlusOneInSequentialMode() + /** + * Restore original environment values. + */ + private function restoreEnvironmentValues(): void { - if (env('TEST_TOKEN') !== null) { - $this->markTestSkipped('Cannot test sequential behavior when TEST_TOKEN is set'); + foreach ($this->originalEnvironmentValues as $key => $value) { + $value['process'] === false + ? putenv($key) + : putenv("{$key}={$value['process']}"); + + if ($value['server_exists']) { + $_SERVER[$key] = $value['server']; + } else { + unset($_SERVER[$key]); + } + + if ($value['environment_exists']) { + $_ENV[$key] = $value['environment']; + } else { + unset($_ENV[$key]); + } } - $base = $this->getBaseRedisDb(); + Env::flushRepository(); + } +} + +class InteractsWithRedisHarness +{ + use InteractsWithRedis; + + public function __construct( + protected ?ApplicationContract $app = null + ) { + } + + /** + * Get the base Redis DB number. + */ + public function baseRedisDb(): int + { + return $this->getBaseRedisDb(); + } - $this->assertSame($base + 1, $this->getSecondaryRedisDb()); + /** + * Get the current primary Redis DB number. + */ + public function parallelRedisDb(): int + { + return $this->getParallelRedisDb(); + } + + /** + * Get the secondary Redis DB number. + */ + public function secondaryRedisDb(): int + { + return $this->getSecondaryRedisDb(); + } + + /** + * Get the configured Redis worker databases. + * + * @return array + */ + public function workerDatabases(): array + { + return $this->redisWorkerDatabases(); } } diff --git a/tests/Integration/Horizon/worker.php b/tests/Integration/Horizon/worker.php index 5b483c2e4..532bedc5b 100644 --- a/tests/Integration/Horizon/worker.php +++ b/tests/Integration/Horizon/worker.php @@ -13,6 +13,7 @@ use Hypervel\Foundation\Application; use Hypervel\Foundation\Console\Kernel as ConsoleKernel; use Hypervel\Foundation\Exceptions\Handler as ExceptionHandler; +use Hypervel\Foundation\Testing\RedisTestDatabases; use Hypervel\Horizon\HorizonServiceProvider; use Hypervel\Queue\Worker; use Hypervel\Queue\WorkerOptions; @@ -48,9 +49,10 @@ // Parallel test isolation: use the same per-worker Redis DB as the test process. // InteractsWithRedis sets this in the test, but this subprocess bootstraps separately. -if ($token = getenv('TEST_TOKEN')) { - $baseDb = (int) $config->get('database.redis.default.database', 0); - $config->set('database.redis.default.database', $baseDb + (int) $token); +$token = getenv('TEST_TOKEN'); + +if (is_string($token)) { + $config->set('database.redis.default.database', RedisTestDatabases::databaseForToken($token)); } $app->register(HorizonServiceProvider::class); diff --git a/tests/Integration/Redis/RedisProxyIntegrationTest.php b/tests/Integration/Redis/RedisProxyIntegrationTest.php index 88f468a76..13cf1fa17 100644 --- a/tests/Integration/Redis/RedisProxyIntegrationTest.php +++ b/tests/Integration/Redis/RedisProxyIntegrationTest.php @@ -264,16 +264,22 @@ public function testPipelineCallbackAndSelect(): void $valueKey = 'pipeline_select_value_' . uniqid(); $redis->set($valueKey, $id = uniqid(), 'EX', 600); - $key = 'pipeline_select_' . uniqid(); - $results = $redis->pipeline(function (PhpRedis $pipe) use ($key) { - $pipe->set($key, "value_{$key}"); - $pipe->incr("{$key}_counter"); - $pipe->get($key); - $pipe->get("{$key}_counter"); - }); + try { + $key = 'pipeline_select_' . uniqid(); + $results = $redis->pipeline(function (PhpRedis $pipe) use ($key) { + $pipe->set($key, "value_{$key}"); + $pipe->incr("{$key}_counter"); + $pipe->get($key); + $pipe->get("{$key}_counter"); + }); - $this->assertCount(4, $results); - $this->assertSame($id, $redis->get($valueKey)); + $this->assertCount(4, $results); + $this->assertSame($id, $redis->get($valueKey)); + } finally { + $redis->select($this->getSecondaryRedisDb()); + $redis->del($valueKey); + $redis->select($this->getParallelRedisDb()); + } } public function testPipelineCallbackAndPipeline(): void @@ -296,11 +302,13 @@ public function testPipelineCallbackAndPipeline(): void $secondaryDb = $this->getSecondaryRedisDb(); $selectKey = 'pipeline_select_junk_' . uniqid(); - go(static function () use ($redis, $secondaryDb, $selectKey) { + $secondaryWriteFinished = new Channel(1); + go(static function () use ($redis, $secondaryDb, $selectKey, $secondaryWriteFinished) { $redis->select($secondaryDb); $redis->set($selectKey, 'x'); $redis->set($selectKey, 'x'); $redis->set($selectKey, 'x'); + $secondaryWriteFinished->push(true); }); $pipelineKey = 'pipeline_junk_' . uniqid(); @@ -309,10 +317,17 @@ public function testPipelineCallbackAndPipeline(): void $openPipeline->set($pipelineKey, 'x'); $openPipeline->set($pipelineKey, 'x'); - $this->assertInstanceOf(PhpRedis::class, $openPipeline); - // The pre-callback set() is queued on the open pipeline connection, so callback exec includes 5 queued results. - $this->assertCount(5, $callbackResults); - $this->assertSame($id, $redis->get($valueKey)); + try { + $this->assertTrue($secondaryWriteFinished->pop()); + $this->assertInstanceOf(PhpRedis::class, $openPipeline); + // The pre-callback set() is queued on the open pipeline connection, so callback exec includes 5 queued results. + $this->assertCount(5, $callbackResults); + $this->assertSame($id, $redis->get($valueKey)); + } finally { + $redis->select($secondaryDb); + $redis->del($selectKey); + $redis->select($this->getParallelRedisDb()); + } } public function testOpenPipelineReshapesWrapperSetArguments(): void diff --git a/tests/Server/ServerStartCommandTest.php b/tests/Server/ServerStartCommandTest.php index 2924d1fab..9272e4d73 100644 --- a/tests/Server/ServerStartCommandTest.php +++ b/tests/Server/ServerStartCommandTest.php @@ -11,8 +11,11 @@ use Hypervel\Foundation\Application; use Hypervel\Server\Commands\ServerStartCommand; use Hypervel\Server\ServerFactory; +use Hypervel\Server\ServerInterface; use Hypervel\Testbench\TestCase; +use InvalidArgumentException; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use Symfony\Component\Console\Command\Command as SymfonyCommand; use Symfony\Component\Console\Input\ArrayInput; @@ -27,7 +30,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testServeCommandFailsFastWhenRunningInConsoleIsTrue() + public function testServeCommandFailsFastWhenRunningInConsoleIsTrue(): void { $command = new ServerStartCommand($this->app); @@ -39,7 +42,7 @@ public function testServeCommandFailsFastWhenRunningInConsoleIsTrue() $command->run(new ArrayInput([]), new NullOutput); } - public function testServeCommandUsesThePlainSymfonyRuntimeBoundary() + public function testServeCommandUsesThePlainSymfonyRuntimeBoundary(): void { $command = new ServerStartCommand($this->app); @@ -47,16 +50,27 @@ public function testServeCommandUsesThePlainSymfonyRuntimeBoundary() $this->assertNotInstanceOf(ConsoleCommand::class, $command); } - public function testServeCommandStartsServerWhenRunningInConsoleIsFalse() + public function testServeCommandStartsServerWhenRunningInConsoleIsFalse(): void { + $serverConfig = [ + 'servers' => [ + [ + 'name' => 'http', + 'type' => ServerInterface::SERVER_HTTP, + 'host' => '0.0.0.0', + 'port' => 9501, + ], + ], + ]; + $serverFactory = m::mock(ServerFactory::class); $serverFactory->shouldReceive('setEventDispatcher')->once()->andReturnSelf(); $serverFactory->shouldReceive('setLogger')->once()->andReturnSelf(); - $serverFactory->shouldReceive('configure')->once()->with(['http' => ['port' => 9501]]); + $serverFactory->shouldReceive('configure')->once()->with($serverConfig); $serverFactory->shouldReceive('start')->once(); $config = m::mock(Repository::class); - $config->shouldReceive('array')->once()->with('server', [])->andReturn(['http' => ['port' => 9501]]); + $config->shouldReceive('array')->once()->with('server', [])->andReturn($serverConfig); $dispatcher = m::mock(DispatcherContract::class); $logger = m::mock(StdoutLoggerInterface::class); @@ -74,4 +88,156 @@ public function testServeCommandStartsServerWhenRunningInConsoleIsFalse() $this->assertSame(0, $result); } + + public function testServeCommandOverridesHttpServerHostAndPort(): void + { + $serverConfig = [ + 'servers' => [ + [ + 'name' => 'reverb', + 'type' => ServerInterface::SERVER_WEBSOCKET, + 'host' => '0.0.0.0', + 'port' => 8080, + ], + [ + 'name' => 'http', + 'type' => ServerInterface::SERVER_HTTP, + 'host' => '0.0.0.0', + 'port' => 9501, + ], + ], + ]; + + $expectedServers = [ + [ + 'name' => 'reverb', + 'type' => ServerInterface::SERVER_WEBSOCKET, + 'host' => '0.0.0.0', + 'port' => 8080, + ], + [ + 'name' => 'http', + 'type' => ServerInterface::SERVER_HTTP, + 'host' => '127.0.0.1', + 'port' => 9502, + ], + ]; + + $serverFactory = m::mock(ServerFactory::class); + $serverFactory->shouldReceive('setEventDispatcher')->once()->andReturnSelf(); + $serverFactory->shouldReceive('setLogger')->once()->andReturnSelf(); + $serverFactory->shouldReceive('configure')->once()->with(['servers' => $expectedServers]); + $serverFactory->shouldReceive('start')->once(); + + $config = m::mock(Repository::class); + $config->shouldReceive('array')->once()->with('server', [])->andReturn($serverConfig); + $config->shouldReceive('set')->once()->with('server.servers', $expectedServers); + + $dispatcher = m::mock(DispatcherContract::class); + $logger = m::mock(StdoutLoggerInterface::class); + + $this->app->instance(ServerFactory::class, $serverFactory); + $this->app->instance('events', $dispatcher); + $this->app->instance(StdoutLoggerInterface::class, $logger); + $this->app->instance('config', $config); + + $command = new ServerStartCommand($this->app); + + Application::getInstance()->setRunningInConsole(false); + + $result = $command->run(new ArrayInput([ + '--host' => '127.0.0.1', + '--port' => '9502', + ]), new NullOutput); + + $this->assertSame(0, $result); + } + + #[DataProvider('invalidServePorts')] + public function testServeCommandRejectsInvalidPortOption(string $port): void + { + $serverFactory = m::mock(ServerFactory::class); + $serverFactory->shouldReceive('setEventDispatcher')->once()->andReturnSelf(); + $serverFactory->shouldReceive('setLogger')->once()->andReturnSelf(); + + $config = m::mock(Repository::class); + $config->shouldReceive('array')->once()->with('server', [])->andReturn([ + 'servers' => [ + [ + 'name' => 'http', + 'type' => ServerInterface::SERVER_HTTP, + 'host' => '0.0.0.0', + 'port' => 9501, + ], + ], + ]); + + $dispatcher = m::mock(DispatcherContract::class); + $logger = m::mock(StdoutLoggerInterface::class); + + $this->app->instance(ServerFactory::class, $serverFactory); + $this->app->instance('events', $dispatcher); + $this->app->instance(StdoutLoggerInterface::class, $logger); + $this->app->instance('config', $config); + + $command = new ServerStartCommand($this->app); + + Application::getInstance()->setRunningInConsole(false); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The serve port must be an integer between 1 and 65535.'); + + $command->run(new ArrayInput(['--port' => $port]), new NullOutput); + } + + /** + * Get invalid serve ports. + * + * @return array + */ + public static function invalidServePorts(): array + { + return [ + ['not-a-port'], + ['0'], + ['-1'], + ['65536'], + ]; + } + + public function testServeCommandRejectsAddressOptionsWithoutHttpServer(): void + { + $serverFactory = m::mock(ServerFactory::class); + $serverFactory->shouldReceive('setEventDispatcher')->once()->andReturnSelf(); + $serverFactory->shouldReceive('setLogger')->once()->andReturnSelf(); + + $config = m::mock(Repository::class); + $config->shouldReceive('array')->once()->with('server', [])->andReturn([ + 'servers' => [ + [ + 'name' => 'reverb', + 'type' => ServerInterface::SERVER_WEBSOCKET, + 'host' => '0.0.0.0', + 'port' => 8080, + ], + ], + ]); + + $dispatcher = m::mock(DispatcherContract::class); + $logger = m::mock(StdoutLoggerInterface::class); + + $this->app->instance(ServerFactory::class, $serverFactory); + $this->app->instance('events', $dispatcher); + $this->app->instance(StdoutLoggerInterface::class, $logger); + $this->app->instance('config', $config); + + $command = new ServerStartCommand($this->app); + + Application::getInstance()->setRunningInConsole(false); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot override server host or port because no HTTP server is configured.'); + + $command->run(new ArrayInput(['--host' => '127.0.0.1']), new NullOutput); + } } diff --git a/tests/Testbench/BootstrapperTest.php b/tests/Testbench/BootstrapperTest.php index 22821fbea..356011e6d 100644 --- a/tests/Testbench/BootstrapperTest.php +++ b/tests/Testbench/BootstrapperTest.php @@ -4,13 +4,19 @@ namespace Hypervel\Tests\Testbench; +use Hypervel\Filesystem\Filesystem; +use Hypervel\Support\Env; use Hypervel\Testbench\Bootstrapper; use Hypervel\Testbench\Foundation\Config; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; use ReflectionClass; +use ReflectionMethod; +use UnexpectedValueException; class BootstrapperTest extends TestCase { + #[Test] public function testFlushStateKeepsRuntimePathForShutdownCleanup() { $reflection = new ReflectionClass(Bootstrapper::class); @@ -31,4 +37,302 @@ public function testFlushStateKeepsRuntimePathForShutdownCleanup() $reflection->setStaticPropertyValue('runtimePath', $previousRuntimePath); } } + + #[Test] + public function itToleratesRuntimeDirectoryDeletionRacesWhenTheDirectoryIsGone(): void + { + $filesystem = new RuntimeDirectoryVanishedFilesystem; + + $this->deleteRuntimeDirectoryWithFilesystem($filesystem); + + $this->assertSame(1, $filesystem->deleteAttempts); + } + + #[Test] + public function itRethrowsRuntimeDirectoryDeletionFailuresWhenTheDirectoryRemains(): void + { + $filesystem = new RuntimeDirectoryStillPresentFilesystem; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('runtime directory still present'); + + try { + $this->deleteRuntimeDirectoryWithFilesystem($filesystem); + } finally { + $this->assertSame(2, $filesystem->deleteAttempts); + } + } + + #[Test] + public function itCopiesThePackageEnvironmentFileIntoTheRuntimeCopy(): void + { + $packagePath = $this->temporaryDirectory('package-env'); + $sourcePath = $this->temporaryDirectory('skeleton-env'); + $runtimePath = null; + + mkdir($packagePath . DIRECTORY_SEPARATOR . 'workbench', 0777, true); + mkdir($sourcePath, 0777, true); + file_put_contents($packagePath . DIRECTORY_SEPARATOR . 'workbench' . DIRECTORY_SEPARATOR . '.env', 'APP_NAME=Workbench'); + file_put_contents($sourcePath . DIRECTORY_SEPARATOR . '.env.example', 'APP_NAME=Skeleton'); + + try { + $this->withRuntimeCopyEnvironment('bootstrapper-package-env', true, function () use ($sourcePath, $packagePath, &$runtimePath): void { + $runtimePath = $this->createRuntimeCopy($sourcePath, $packagePath); + + $this->assertFileExists($runtimePath . DIRECTORY_SEPARATOR . '.env'); + $this->assertSame('APP_NAME=Workbench', file_get_contents($runtimePath . DIRECTORY_SEPARATOR . '.env')); + }); + } finally { + $this->deleteDirectory($packagePath); + $this->deleteDirectory($sourcePath); + $this->deleteDirectory($runtimePath); + } + } + + #[Test] + public function itCopiesTheSkeletonEnvironmentExampleIntoTheRuntimeCopyWhenNoPackageEnvironmentFileExists(): void + { + $packagePath = $this->temporaryDirectory('package-no-env'); + $sourcePath = $this->temporaryDirectory('skeleton-no-env'); + $runtimePath = null; + + mkdir($packagePath, 0777, true); + mkdir($sourcePath, 0777, true); + file_put_contents($sourcePath . DIRECTORY_SEPARATOR . '.env.example', 'REDIS_PASSWORD=null'); + + try { + $this->withRuntimeCopyEnvironment('bootstrapper-skeleton-env', true, function () use ($sourcePath, $packagePath, &$runtimePath): void { + $runtimePath = $this->createRuntimeCopy($sourcePath, $packagePath); + + $this->assertFileExists($runtimePath . DIRECTORY_SEPARATOR . '.env'); + $this->assertFileExists($runtimePath . DIRECTORY_SEPARATOR . '.env.example'); + $this->assertSame('REDIS_PASSWORD=null', file_get_contents($runtimePath . DIRECTORY_SEPARATOR . '.env')); + }); + } finally { + $this->deleteDirectory($packagePath); + $this->deleteDirectory($sourcePath); + $this->deleteDirectory($runtimePath); + } + } + + #[Test] + public function itDoesNotCopyPackageOrSkeletonEnvironmentFilesOutsidePackageTesterMode(): void + { + $packagePath = $this->temporaryDirectory('package-raw-env'); + $sourcePath = $this->temporaryDirectory('skeleton-raw-env'); + $runtimePath = null; + + mkdir($packagePath . DIRECTORY_SEPARATOR . 'workbench', 0777, true); + mkdir($sourcePath, 0777, true); + file_put_contents($packagePath . DIRECTORY_SEPARATOR . 'workbench' . DIRECTORY_SEPARATOR . '.env', 'APP_NAME=Workbench'); + file_put_contents($sourcePath . DIRECTORY_SEPARATOR . '.env.example', 'APP_NAME=Skeleton'); + + try { + $this->withRuntimeCopyEnvironment('bootstrapper-raw-env', false, function () use ($sourcePath, $packagePath, &$runtimePath): void { + $runtimePath = $this->createRuntimeCopy($sourcePath, $packagePath); + + $this->assertFileDoesNotExist($runtimePath . DIRECTORY_SEPARATOR . '.env'); + $this->assertFileExists($runtimePath . DIRECTORY_SEPARATOR . '.env.example'); + $this->assertSame('APP_NAME=Skeleton', file_get_contents($runtimePath . DIRECTORY_SEPARATOR . '.env.example')); + }); + } finally { + $this->deleteDirectory($packagePath); + $this->deleteDirectory($sourcePath); + $this->deleteDirectory($runtimePath); + } + } + + /** + * Create a temporary test directory. + */ + private function temporaryDirectory(string $name): string + { + return sys_get_temp_dir() . DIRECTORY_SEPARATOR . "hypervel-bootstrapper-{$name}-" + . getmypid() . '-' . bin2hex(random_bytes(6)); + } + + /** + * Create a runtime copy through Bootstrapper's protected method. + */ + private function createRuntimeCopy(string $sourcePath, string $workingPath): string + { + $method = new ReflectionMethod(Bootstrapper::class, 'createRuntimeCopy'); + $method->setAccessible(true); + + return $method->invoke(null, $sourcePath, $workingPath); + } + + /** + * Delete a runtime directory through Bootstrapper with a fake filesystem. + */ + private function deleteRuntimeDirectoryWithFilesystem(Filesystem $filesystem): void + { + $reflection = new ReflectionClass(Bootstrapper::class); + $method = new ReflectionMethod(Bootstrapper::class, 'deleteRuntimeDirectory'); + $previousFilesystem = $reflection->getStaticPropertyValue('filesystem'); + + $method->setAccessible(true); + + try { + $reflection->setStaticPropertyValue('filesystem', $filesystem); + $method->invoke(null, '/tmp/hypervel-runtime-copy'); + } finally { + $reflection->setStaticPropertyValue('filesystem', $previousFilesystem); + } + } + + /** + * Run a callback with isolated runtime-copy environment state. + */ + private function withRuntimeCopyEnvironment(string $token, bool $packageTester, callable $callback): void + { + $reflection = new ReflectionClass(Bootstrapper::class); + $previousServerToken = $_SERVER['TEST_TOKEN'] ?? null; + $previousEnvironmentToken = $_ENV['TEST_TOKEN'] ?? null; + $previousServerPackageTester = $_SERVER['TESTBENCH_PACKAGE_TESTER'] ?? null; + $previousEnvironmentPackageTester = $_ENV['TESTBENCH_PACKAGE_TESTER'] ?? null; + $previousProcessPackageTester = getenv('TESTBENCH_PACKAGE_TESTER'); + $previousRuntimePath = $reflection->getStaticPropertyValue('runtimePath'); + + try { + $this->setTestToken($token); + + if ($packageTester) { + $this->setPackageTester(); + } else { + $this->restorePackageTester(null, null, false); + } + + $callback(); + } finally { + $this->restoreTestToken($previousServerToken, $previousEnvironmentToken); + $this->restorePackageTester($previousServerPackageTester, $previousEnvironmentPackageTester, $previousProcessPackageTester); + $reflection->setStaticPropertyValue('runtimePath', $previousRuntimePath); + } + } + + /** + * Set an isolated test token for runtime copy paths. + */ + private function setTestToken(string $token): void + { + $_SERVER['TEST_TOKEN'] = $token; + $_ENV['TEST_TOKEN'] = $token; + } + + /** + * Set package tester mode for runtime copy behavior. + */ + private function setPackageTester(): void + { + $_SERVER['TESTBENCH_PACKAGE_TESTER'] = '(true)'; + $_ENV['TESTBENCH_PACKAGE_TESTER'] = '(true)'; + putenv('TESTBENCH_PACKAGE_TESTER=(true)'); + Env::flushRepository(); + } + + /** + * Restore the previous test token. + */ + private function restoreTestToken(?string $serverToken, ?string $environmentToken): void + { + if ($serverToken === null) { + unset($_SERVER['TEST_TOKEN']); + } else { + $_SERVER['TEST_TOKEN'] = $serverToken; + } + + if ($environmentToken === null) { + unset($_ENV['TEST_TOKEN']); + } else { + $_ENV['TEST_TOKEN'] = $environmentToken; + } + } + + /** + * Restore package tester mode. + */ + private function restorePackageTester( + ?string $serverPackageTester, + ?string $environmentPackageTester, + string|false $processPackageTester + ): void { + if ($processPackageTester === false) { + putenv('TESTBENCH_PACKAGE_TESTER'); + } else { + putenv("TESTBENCH_PACKAGE_TESTER={$processPackageTester}"); + } + + if ($serverPackageTester === null) { + unset($_SERVER['TESTBENCH_PACKAGE_TESTER']); + } else { + $_SERVER['TESTBENCH_PACKAGE_TESTER'] = $serverPackageTester; + } + + if ($environmentPackageTester === null) { + unset($_ENV['TESTBENCH_PACKAGE_TESTER']); + } else { + $_ENV['TESTBENCH_PACKAGE_TESTER'] = $environmentPackageTester; + } + + Env::flushRepository(); + } + + /** + * Delete a temporary directory. + */ + private function deleteDirectory(?string $path): void + { + if ($path === null || ! is_dir($path)) { + return; + } + + (new Filesystem)->deleteDirectory($path); + } +} + +class RuntimeDirectoryVanishedFilesystem extends Filesystem +{ + public int $deleteAttempts = 0; + + /** + * Determine if the directory still exists. + */ + public function isDirectory(string $directory): bool + { + return $this->deleteAttempts === 0; + } + + /** + * Simulate losing a concurrent deletion race. + */ + public function deleteDirectory(string $directory, bool $preserve = false): bool + { + ++$this->deleteAttempts; + + throw new UnexpectedValueException('runtime directory vanished'); + } +} + +class RuntimeDirectoryStillPresentFilesystem extends Filesystem +{ + public int $deleteAttempts = 0; + + /** + * Determine if the directory still exists. + */ + public function isDirectory(string $directory): bool + { + return true; + } + + /** + * Simulate a filesystem failure that leaves the directory behind. + */ + public function deleteDirectory(string $directory, bool $preserve = false): bool + { + ++$this->deleteAttempts; + + throw new UnexpectedValueException('runtime directory still present'); + } } diff --git a/tests/Testbench/CommanderServeTest.php b/tests/Testbench/CommanderServeTest.php index fa5b6defe..d8a3fd5a0 100644 --- a/tests/Testbench/CommanderServeTest.php +++ b/tests/Testbench/CommanderServeTest.php @@ -74,11 +74,9 @@ private function startServeProcess(): array $this->registerShutdownSafetyNet(); $serverPort = $this->servePort(); - $process = remote('serve --no-ansi', [ + $process = remote("serve --host=127.0.0.1 --port={$serverPort} --no-ansi", [ 'APP_DEBUG' => 'true', 'APP_ENV' => 'workbench', - 'HTTP_SERVER_HOST' => '127.0.0.1', - 'HTTP_SERVER_PORT' => (string) $serverPort, ]); $process->setTimeout(20); @@ -296,7 +294,7 @@ private function waitForServeStartup(ProcessDecorator $process, int $serverPort) do { if (! $process->isRunning()) { - $this->fail("Serve process exited before accepting connections.\n{$this->combinedOutput($process)}"); + $this->fail("Serve process exited before accepting connections on port {$serverPort}.\n{$this->combinedOutput($process)}"); } if ($this->canConnectToServePort($serverPort)) { @@ -306,7 +304,7 @@ private function waitForServeStartup(ProcessDecorator $process, int $serverPort) usleep(100_000); } while (microtime(true) < $deadline); - $this->fail("Serve process did not accept connections.\n{$this->combinedOutput($process)}"); + $this->fail("Serve process did not accept connections on port {$serverPort}.\n{$this->combinedOutput($process)}"); } /** @@ -341,6 +339,22 @@ private function canConnectToServePort(int $serverPort): bool * Reserve a free local port for the serve smoke test. */ private function servePort(): int + { + for ($attempt = 0; $attempt < 20; ++$attempt) { + $port = $this->reserveServePort(); + + if ($port > 10_000) { + return $port; + } + } + + $this->fail('Unable to reserve a high TCP port for the serve smoke test.'); + } + + /** + * Reserve a free local TCP port. + */ + private function reserveServePort(): int { $socket = stream_socket_server('tcp://127.0.0.1:0', $errorNumber, $errorMessage); diff --git a/tests/Testbench/Concerns/CreatesApplicationTest.php b/tests/Testbench/Concerns/CreatesApplicationTest.php index 1bed2b404..4d80b98ba 100644 --- a/tests/Testbench/Concerns/CreatesApplicationTest.php +++ b/tests/Testbench/Concerns/CreatesApplicationTest.php @@ -79,6 +79,40 @@ public function testAfterLoadingEnvironmentFiresThroughTestbenchPath() $this->assertCount(count($listeners) + 1, $updatedListeners); } + + public function testParallelCachePathSanitizesParaTestWorkerToken(): void + { + $previousServerToken = $_SERVER['TEST_TOKEN'] ?? null; + $previousEnvironmentToken = $_ENV['TEST_TOKEN'] ?? null; + $previousRoutesCache = $_SERVER['APP_ROUTES_CACHE'] ?? null; + + try { + $_SERVER['TEST_TOKEN'] = 'worker/token:one'; + $_ENV['TEST_TOKEN'] = 'worker/token:one'; + + $this->configureParallelCachePaths(); + + $this->assertSame('cache/routes-v7-test-worker_token_one.php', $_SERVER['APP_ROUTES_CACHE']); + } finally { + if ($previousServerToken === null) { + unset($_SERVER['TEST_TOKEN']); + } else { + $_SERVER['TEST_TOKEN'] = $previousServerToken; + } + + if ($previousEnvironmentToken === null) { + unset($_ENV['TEST_TOKEN']); + } else { + $_ENV['TEST_TOKEN'] = $previousEnvironmentToken; + } + + if ($previousRoutesCache === null) { + unset($_SERVER['APP_ROUTES_CACHE']); + } else { + $_SERVER['APP_ROUTES_CACHE'] = $previousRoutesCache; + } + } + } } /** diff --git a/tests/Testbench/Concerns/DefineCacheRoutesTest.php b/tests/Testbench/Concerns/DefineCacheRoutesTest.php index dfec60b31..c6c4f3d6a 100644 --- a/tests/Testbench/Concerns/DefineCacheRoutesTest.php +++ b/tests/Testbench/Concerns/DefineCacheRoutesTest.php @@ -87,6 +87,54 @@ public function testDefineCacheRoutesHasRunFlagIsSet() $this->assertTrue($this->requireApplicationCachedRoutesHasRun); } + public function testDefineCacheRoutesTracksOwnedRouteFile() + { + $this->defineCacheRoutes(<<<'PHP' + 'first'); +PHP, false); + + $this->assertCount(1, $this->testbenchRouteFiles); + $this->assertFileExists($this->testbenchRouteFiles[0]); + } + + public function testDefineCacheRoutesDeletesOnlyOwnedRouteFiles() + { + $this->defineCacheRoutes(<<<'PHP' + 'owned'); +PHP, false); + + $ownedRouteFile = $this->testbenchRouteFiles[0]; + $siblingRouteFile = $this->app->basePath('routes/testbench-sibling.php'); + + file_put_contents($siblingRouteFile, 'assertFileExists($ownedRouteFile); + $this->assertFileExists($siblingRouteFile); + + $this->callBeforeApplicationDestroyedCallbacks(); + + $this->assertFileDoesNotExist($ownedRouteFile); + $this->assertFileExists($siblingRouteFile); + } finally { + @unlink($siblingRouteFile); + } + } + + public function testTestbenchRouteFilePathIsUniquePerCall() + { + $firstRouteFile = $this->testbenchRouteFilePath($this->app->basePath()); + $secondRouteFile = $this->testbenchRouteFilePath($this->app->basePath()); + + $this->assertNotSame($firstRouteFile, $secondRouteFile); + $this->assertStringStartsWith($this->app->basePath('routes/testbench-'), $firstRouteFile); + $this->assertStringEndsWith('.php', $firstRouteFile); + } + public function testCacheFileExistsAfterDefineCacheRoutes() { $this->defineCacheRoutes(<<<'PHP' diff --git a/tests/Testbench/Concerns/PreservesSkeletonFiles.php b/tests/Testbench/Concerns/PreservesSkeletonFiles.php new file mode 100644 index 000000000..6d9235ba4 --- /dev/null +++ b/tests/Testbench/Concerns/PreservesSkeletonFiles.php @@ -0,0 +1,70 @@ + + */ + private array $preservedFiles = []; + + /** + * Preserve files that may already exist in the shared runtime skeleton. + * + * @param array $paths + */ + private function preserveFiles(array $paths): void + { + foreach ($paths as $path) { + $this->preservedFiles[$path] = $this->filesystem->isFile($path) + ? $this->filesystem->get($path) + : null; + } + } + + /** + * Restore files that existed before the command test. + */ + private function restorePreservedFiles(): void + { + foreach ($this->preservedFiles as $path => $contents) { + if ($contents === null) { + $this->deletePath($path); + + continue; + } + + $this->filesystem->ensureDirectoryExists(dirname($path)); + $this->filesystem->put($path, $contents); + } + + $this->preservedFiles = []; + } + + /** + * Delete a file, directory, or symlink if it exists. + */ + private function deletePath(string $path): void + { + if (is_link($path)) { + unlink($path); + + return; + } + + if ($this->filesystem->isDirectory($path)) { + $this->filesystem->deleteDirectory($path); + + return; + } + + if ($this->filesystem->exists($path)) { + $this->filesystem->delete($path); + } + } +} diff --git a/tests/Testbench/Foundation/Console/PurgeSkeletonCommandTest.php b/tests/Testbench/Foundation/Console/PurgeSkeletonCommandTest.php index e314f39b8..6715cdcca 100644 --- a/tests/Testbench/Foundation/Console/PurgeSkeletonCommandTest.php +++ b/tests/Testbench/Foundation/Console/PurgeSkeletonCommandTest.php @@ -9,6 +9,7 @@ use Hypervel\Testbench\Contracts\Config as ConfigContract; use Hypervel\Testbench\Foundation\Console\TerminatingConsole; use Hypervel\Testbench\TestbenchServiceProvider; +use Hypervel\Tests\Testbench\Concerns\PreservesSkeletonFiles; use Hypervel\Tests\Testbench\Fixtures\Providers\Phase2ConsoleServiceProvider; use Hypervel\Tests\Testbench\TestCase; use Override; @@ -21,6 +22,8 @@ #[RequiresOperatingSystem('Linux|Darwin')] class PurgeSkeletonCommandTest extends TestCase { + use PreservesSkeletonFiles; + private Filesystem $filesystem; #[Override] @@ -29,6 +32,11 @@ protected function setUp(): void parent::setUp(); $this->filesystem = new Filesystem; + + $this->preserveFiles([ + $this->app->basePath('.env'), + $this->app->basePath('.env.backup'), + ]); } #[Override] @@ -36,6 +44,7 @@ protected function tearDown(): void { TerminatingConsole::flush(); $this->cleanUpPurgeSkeletonArtifacts(); + $this->restorePreservedFiles(); parent::tearDown(); } @@ -178,26 +187,4 @@ private function purgeSkeletonArtifactPaths(): array $this->app->basePath('vendor'), ]; } - - /** - * Delete a file, directory, or symlink if it exists. - */ - private function deletePath(string $path): void - { - if (is_link($path)) { - unlink($path); - - return; - } - - if ($this->filesystem->isDirectory($path)) { - $this->filesystem->deleteDirectory($path); - - return; - } - - if ($this->filesystem->exists($path)) { - $this->filesystem->delete($path); - } - } } diff --git a/tests/Testbench/Foundation/Console/SyncSkeletonCommandTest.php b/tests/Testbench/Foundation/Console/SyncSkeletonCommandTest.php index 1add531f8..9e1637e86 100644 --- a/tests/Testbench/Foundation/Console/SyncSkeletonCommandTest.php +++ b/tests/Testbench/Foundation/Console/SyncSkeletonCommandTest.php @@ -8,6 +8,7 @@ use Hypervel\Filesystem\Filesystem; use Hypervel\Testbench\Contracts\Config as ConfigContract; use Hypervel\Testbench\TestbenchServiceProvider; +use Hypervel\Tests\Testbench\Concerns\PreservesSkeletonFiles; use Hypervel\Tests\Testbench\Fixtures\Providers\Phase2ConsoleServiceProvider; use Hypervel\Tests\Testbench\TestCase; use Override; @@ -22,6 +23,8 @@ #[RequiresOperatingSystem('Linux|Darwin')] class SyncSkeletonCommandTest extends TestCase { + use PreservesSkeletonFiles; + private Filesystem $filesystem; #[Override] @@ -30,12 +33,17 @@ protected function setUp(): void parent::setUp(); $this->filesystem = new Filesystem; + + $this->preserveFiles([ + $this->app->basePath('.env'), + ]); } #[Override] protected function tearDown(): void { $this->cleanUpSyncSkeletonArtifacts(); + $this->restorePreservedFiles(); parent::tearDown(); } @@ -103,28 +111,6 @@ public function itDoesNotLeakMutatedWorkbenchConfigAcrossTests() ], $config->getWorkbenchAttributes()['sync']); } - /** - * Delete a file, directory, or symlink if it exists. - */ - private function deletePath(string $path): void - { - if (is_link($path)) { - unlink($path); - - return; - } - - if ($this->filesystem->isDirectory($path)) { - $this->filesystem->deleteDirectory($path); - - return; - } - - if ($this->filesystem->exists($path)) { - $this->filesystem->delete($path); - } - } - /** * Remove every runtime artifact this test may create inside the shared worker skeleton. */ diff --git a/tests/Testbench/Foundation/Console/TestCommandTest.php b/tests/Testbench/Foundation/Console/TestCommandTest.php index 4d6e7996a..986d37b95 100644 --- a/tests/Testbench/Foundation/Console/TestCommandTest.php +++ b/tests/Testbench/Foundation/Console/TestCommandTest.php @@ -4,12 +4,16 @@ namespace Hypervel\Tests\Testbench\Foundation\Console; +use Hypervel\Testbench\Bootstrapper; +use Hypervel\Testbench\Foundation\Config; use Hypervel\Testbench\Foundation\Console\TestCommand; use Hypervel\Testbench\TestCase; use Override; use PHPUnit\Framework\Attributes\Test; +use ReflectionClass; use function Hypervel\Testbench\package_path; +use function Hypervel\Testbench\parse_environment_variables; class TestCommandTest extends TestCase { @@ -79,7 +83,81 @@ public function itBuildsPhpunitEnvironmentVariablesForPackageTests(): void $this->assertIsString($variables[TestCommand::PROFILE_DIRECTORY_ENV]); $this->assertSame('(true)', $variables['TESTBENCH_PACKAGE_TESTER']); $this->assertSame(package_path(), $variables['TESTBENCH_WORKING_PATH']); - $this->assertSame($this->app->basePath(), $variables['TESTBENCH_APP_BASE_PATH']); + $this->assertArrayNotHasKey('TESTBENCH_APP_BASE_PATH', $variables); + } + + #[Test] + public function itDoesNotForwardParentRuntimeEnvironmentVariablesForPackageTests(): void + { + $previousParentRuntimeServerExists = array_key_exists('HYPERVEL_TEST_PARENT_RUNTIME_ENV', $_SERVER); + $previousParentRuntimeServer = $_SERVER['HYPERVEL_TEST_PARENT_RUNTIME_ENV'] ?? null; + $previousParentRuntimeEnvironmentExists = array_key_exists('HYPERVEL_TEST_PARENT_RUNTIME_ENV', $_ENV); + $previousParentRuntimeEnvironment = $_ENV['HYPERVEL_TEST_PARENT_RUNTIME_ENV'] ?? null; + $previousRedisPasswordServerExists = array_key_exists('REDIS_PASSWORD', $_SERVER); + $previousRedisPasswordServer = $_SERVER['REDIS_PASSWORD'] ?? null; + $previousRedisPasswordEnvironmentExists = array_key_exists('REDIS_PASSWORD', $_ENV); + $previousRedisPasswordEnvironment = $_ENV['REDIS_PASSWORD'] ?? null; + + try { + $_SERVER['HYPERVEL_TEST_PARENT_RUNTIME_ENV'] = 'parent'; + $_ENV['HYPERVEL_TEST_PARENT_RUNTIME_ENV'] = 'parent'; + $_SERVER['REDIS_PASSWORD'] = 'null'; + $_ENV['REDIS_PASSWORD'] = 'null'; + + $command = new TestCommandHarness; + $command->setHypervel($this->app); + $variables = $command->phpunitEnvironmentVariablesPublic(); + + $this->assertArrayNotHasKey('HYPERVEL_TEST_PARENT_RUNTIME_ENV', $variables); + $this->assertArrayNotHasKey('REDIS_PASSWORD', $variables); + $this->assertSame('(true)', $variables['TESTBENCH_PACKAGE_TESTER']); + } finally { + $this->restoreSuperglobalValue($_SERVER, 'HYPERVEL_TEST_PARENT_RUNTIME_ENV', $previousParentRuntimeServerExists, $previousParentRuntimeServer); + $this->restoreSuperglobalValue($_ENV, 'HYPERVEL_TEST_PARENT_RUNTIME_ENV', $previousParentRuntimeEnvironmentExists, $previousParentRuntimeEnvironment); + $this->restoreSuperglobalValue($_SERVER, 'REDIS_PASSWORD', $previousRedisPasswordServerExists, $previousRedisPasswordServer); + $this->restoreSuperglobalValue($_ENV, 'REDIS_PASSWORD', $previousRedisPasswordEnvironmentExists, $previousRedisPasswordEnvironment); + } + } + + #[Test] + public function itForwardsConfiguredEnvironmentVariablesForPackageTests(): void + { + $this->withTestbenchConfiguration([ + 'env' => parse_environment_variables([ + 'HYPERVEL_TEST_PACKAGE_ENV' => 'configured', + 'HYPERVEL_TEST_EMPTY_ENV' => '', + 'HYPERVEL_TEST_FALSE_ENV' => false, + ]), + ], function (): void { + $command = new TestCommandHarness; + $command->setHypervel($this->app); + $variables = $command->phpunitEnvironmentVariablesPublic(); + + $this->assertSame('configured', $variables['HYPERVEL_TEST_PACKAGE_ENV']); + $this->assertSame('', $variables['HYPERVEL_TEST_EMPTY_ENV']); + $this->assertSame('(false)', $variables['HYPERVEL_TEST_FALSE_ENV']); + }); + } + + #[Test] + public function packageCommandVariablesOverrideConfiguredEnvironmentVariables(): void + { + $this->withTestbenchConfiguration([ + 'env' => parse_environment_variables([ + 'APP_ENV' => 'local', + 'TESTBENCH_PACKAGE_TESTER' => false, + 'TESTBENCH_WORKING_PATH' => '/tmp/wrong', + ]), + ], function (): void { + $command = new TestCommandHarness; + $command->setHypervel($this->app); + $variables = $command->phpunitEnvironmentVariablesPublic(); + + $this->assertSame('testing', $variables['APP_ENV']); + $this->assertSame('(true)', $variables['TESTBENCH_PACKAGE_TESTER']); + $this->assertSame(package_path(), $variables['TESTBENCH_WORKING_PATH']); + $this->assertArrayNotHasKey('TESTBENCH_APP_BASE_PATH', $variables); + }); } #[Test] @@ -113,7 +191,42 @@ public function itBuildsParatestArgumentsAndEnvironmentVariablesForPackageTests( $this->assertTrue($variables['HYPERVEL_PARALLEL_TESTING_WITHOUT_CACHE']); $this->assertSame('(true)', $variables['TESTBENCH_PACKAGE_TESTER']); $this->assertSame(package_path(), $variables['TESTBENCH_WORKING_PATH']); - $this->assertSame($this->app->basePath(), $variables['TESTBENCH_APP_BASE_PATH']); + $this->assertArrayNotHasKey('TESTBENCH_APP_BASE_PATH', $variables); + } + + /** + * Run a callback with temporary Testbench configuration. + * + * @param array $attributes + */ + private function withTestbenchConfiguration(array $attributes, callable $callback): void + { + $reflection = new ReflectionClass(Bootstrapper::class); + $previousConfiguration = $reflection->getStaticPropertyValue('configuration'); + + try { + $reflection->setStaticPropertyValue('configuration', new Config($attributes)); + + $callback(); + } finally { + $reflection->setStaticPropertyValue('configuration', $previousConfiguration); + } + } + + /** + * Restore a superglobal value. + * + * @param array $values + */ + private function restoreSuperglobalValue(array &$values, string $key, bool $exists, mixed $value): void + { + if (! $exists) { + unset($values[$key]); + + return; + } + + $values[$key] = $value; } } diff --git a/tests/Testbench/Foundation/EnvTest.php b/tests/Testbench/Foundation/EnvTest.php index fae3dcbf0..1337e6c7c 100644 --- a/tests/Testbench/Foundation/EnvTest.php +++ b/tests/Testbench/Foundation/EnvTest.php @@ -12,7 +12,7 @@ class EnvTest extends TestCase { #[Test] - public function itCanDeterminedHasEnvValues() + public function itCanDeterminedHasEnvValues(): void { $_ENV['TESTING_TRUE_EXAMPLE'] = true; $_ENV['TESTING_FALSE_EXAMPLE'] = false; @@ -34,7 +34,7 @@ public function itCanDeterminedHasEnvValues() #[Test] #[WithEnv('TESTING_USING_ATTRIBUTE', '(true)')] - public function itCanCorrectlyForwardEnvValues() + public function itCanCorrectlyForwardEnvValues(): void { $_ENV['TESTING_TRUE_EXAMPLE'] = true; $_ENV['TESTING_FALSE_EXAMPLE'] = false; diff --git a/tests/Testbench/Foundation/Fixtures/PackageManifest/build-manifest.php b/tests/Testbench/Foundation/Fixtures/PackageManifest/build-manifest.php new file mode 100644 index 000000000..7e6ef2351 --- /dev/null +++ b/tests/Testbench/Foundation/Fixtures/PackageManifest/build-manifest.php @@ -0,0 +1,21 @@ +build(); diff --git a/tests/Testbench/Foundation/PackageManifestPackageTesterTest.php b/tests/Testbench/Foundation/PackageManifestPackageTesterTest.php new file mode 100644 index 000000000..8d54bd209 --- /dev/null +++ b/tests/Testbench/Foundation/PackageManifestPackageTesterTest.php @@ -0,0 +1,105 @@ +manifestDirectory = ParallelTesting::tempDir('PackageManifestPackageTesterTest'); + $this->fixturePath = __DIR__ . '/Fixtures/PackageManifest'; + + (new Filesystem)->ensureDirectoryExists($this->manifestDirectory); + } + + #[Override] + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->manifestDirectory); + + parent::tearDown(); + } + + #[Test] + public function itAddsRootMetadataWhenRunningInsidePackageTester(): void + { + $manifest = $this->buildManifest( + manifestName: 'package-tester', + env: ['TESTBENCH_PACKAGE_TESTER' => '(true)'], + ); + + $this->assertArrayHasKey('testbench/example', $manifest); + } + + #[Test] + public function itDoesNotAddRootMetadataOutsideCliAndPackageTesterMode(): void + { + $manifest = $this->buildManifest( + manifestName: 'without-cli-or-package-tester', + env: ['TESTBENCH_PACKAGE_TESTER' => false], + ); + + $this->assertArrayNotHasKey('testbench/example', $manifest); + } + + #[Test] + public function itAddsRootMetadataWhenRunningInsideTheTestbenchCli(): void + { + $manifest = $this->buildManifest( + manifestName: 'testbench-cli', + env: ['TESTBENCH_PACKAGE_TESTER' => false], + arguments: ['--testbench-core'] + ); + + $this->assertArrayHasKey('testbench/example', $manifest); + } + + /** + * Build the package manifest in a fresh PHP process. + * + * @param array $env + * @param array $arguments + * @return array + */ + private function buildManifest(string $manifestName, array $env = [], array $arguments = []): array + { + $manifestPath = $this->manifestDirectory . '/' . $manifestName . '.php'; + + $process = new Process( + command: [ + php_binary(), + $this->fixturePath . '/build-manifest.php', + $manifestPath, + ...$arguments, + ], + env: [ + 'TESTBENCH_WORKING_PATH' => $this->fixturePath, + ...$env, + ], + ); + + $process->mustRun(); + + /** @var array $manifest */ + $manifest = require $manifestPath; + + return $manifest; + } +} diff --git a/tests/Testbench/Foundation/PackageManifestTest.php b/tests/Testbench/Foundation/PackageManifestTest.php index b69b52b86..4a97961cd 100644 --- a/tests/Testbench/Foundation/PackageManifestTest.php +++ b/tests/Testbench/Foundation/PackageManifestTest.php @@ -171,6 +171,25 @@ public function itCanFilterManifestUsingTheTestbenchIgnoreList(): void $this->assertSame([], $manifest->aliases()); } + #[Test] + public function itCanFilterManifestUsingSpecificPackageNames(): void + { + $manifest = $this->makeManifest( + testbench: $this->makeTestbench(['testbench/example']), + rootPackage: $this->rootPackageFixture() + ); + + $manifest->build(); + + $cached = require $this->manifestPath; + + $this->assertArrayHasKey('testbench/example', $cached); + $this->assertFalse($manifest->hasPackage('testbench/example')); + $this->assertTrue($manifest->hasPackage('vendor-a/package-a')); + $this->assertContains('Hypervel\Tests\Testbench\Fixtures\Providers\ChildServiceProvider', $manifest->providers()); + $this->assertArrayNotHasKey('RootAlias', $manifest->aliases()); + } + #[Test] public function itCanRetainRequiredPackagesWhenDiscoveryIsDisabled(): void { diff --git a/tests/Testbench/WithWorkbenchTest.php b/tests/Testbench/WithWorkbenchTest.php index e00366969..2019f82cd 100644 --- a/tests/Testbench/WithWorkbenchTest.php +++ b/tests/Testbench/WithWorkbenchTest.php @@ -32,7 +32,7 @@ public function itCanBeResolved() 'env' => ["APP_NAME='Testbench'"], 'bootstrappers' => [], 'providers' => ['Workbench\App\Providers\WorkbenchServiceProvider'], - 'dont-discover' => [], + 'dont-discover' => ['hypervel/components'], ], $cachedConfig->getExtraAttributes()); } @@ -75,6 +75,38 @@ public function itCanAutoDetectPackagesViaBootstrapProvidersFile() $this->assertContains('Workbench\App\Providers\AppServiceProvider', $loadedProviders); } + #[Test] + public function itIgnoresStrayTestbenchAppBasePathEnvironmentValues() + { + $previousAppBasePathExists = array_key_exists('APP_BASE_PATH', $_ENV); + $previousAppBasePath = $_ENV['APP_BASE_PATH'] ?? null; + $previousTestbenchAppBasePathExists = array_key_exists('TESTBENCH_APP_BASE_PATH', $_ENV); + $previousTestbenchAppBasePath = $_ENV['TESTBENCH_APP_BASE_PATH'] ?? null; + + try { + unset($_ENV['APP_BASE_PATH']); + $_ENV['TESTBENCH_APP_BASE_PATH'] = '/tmp/parent-runtime-clone'; + + $this->assertNull(static::applicationBasePathUsingWorkbench()); + + $_ENV['APP_BASE_PATH'] = '/tmp/user-override'; + + $this->assertSame('/tmp/user-override', static::applicationBasePathUsingWorkbench()); + } finally { + if ($previousAppBasePathExists) { + $_ENV['APP_BASE_PATH'] = $previousAppBasePath; + } else { + unset($_ENV['APP_BASE_PATH']); + } + + if ($previousTestbenchAppBasePathExists) { + $_ENV['TESTBENCH_APP_BASE_PATH'] = $previousTestbenchAppBasePath; + } else { + unset($_ENV['TESTBENCH_APP_BASE_PATH']); + } + } + } + #[Test] public function itCanResolveUserModelFromWorkbench() { diff --git a/tests/Testing/Console/TestCommandTest.php b/tests/Testing/Console/TestCommandTest.php index 769b13084..ecb2130c2 100644 --- a/tests/Testing/Console/TestCommandTest.php +++ b/tests/Testing/Console/TestCommandTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Testing\Console; use FilesystemIterator; +use Hypervel\Support\Env; use Hypervel\Testbench\TestCase; use Hypervel\Testing\Console\TestCommand; use Hypervel\Testing\ParallelRunner; @@ -188,6 +189,65 @@ public function itUsesTheRequestedEnvironmentForChildProcesses(): void $this->assertNotContains('--env=ci', $paratestArguments); } + #[Test] + public function itClearsConfiguredEnvironmentVariablesFromEveryEnvironmentStore(): void + { + $basePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'hypervel-test-command-env-' + . getmypid() . '-' . bin2hex(random_bytes(6)); + $environmentFile = '.env.command-clear'; + $keys = [ + 'HYPERVEL_TEST_COMMAND_CLEAR_ONE', + 'HYPERVEL_TEST_COMMAND_CLEAR_TWO', + ]; + + mkdir($basePath, 0777, true); + file_put_contents($basePath . DIRECTORY_SEPARATOR . $environmentFile, implode("\n", [ + 'HYPERVEL_TEST_COMMAND_CLEAR_ONE=one', + 'HYPERVEL_TEST_COMMAND_CLEAR_TWO=two', + ])); + + $originalEnvironmentPath = $this->app->environmentPath(); + $originalEnvironmentFile = $this->app->environmentFile(); + + foreach ($keys as $key) { + $_SERVER[$key] = 'server'; + $_ENV[$key] = 'env'; + putenv("{$key}=process"); + } + + Env::enablePutenv(); + + $command = new TestCommandHarness; + $command->setHypervel($this->app); + + try { + $this->app->useEnvironmentPath($basePath); + $this->app->loadEnvironmentFrom($environmentFile); + + $command->clearEnvPublic(); + + foreach ($keys as $key) { + $this->assertArrayNotHasKey($key, $_SERVER); + $this->assertArrayNotHasKey($key, $_ENV); + $this->assertFalse(getenv($key)); + } + } finally { + if (is_string($originalEnvironmentPath)) { + $this->app->useEnvironmentPath($originalEnvironmentPath); + } + + $this->app->loadEnvironmentFrom($originalEnvironmentFile); + + foreach ($keys as $key) { + unset($_SERVER[$key], $_ENV[$key]); + putenv($key); + } + + Env::flushRepository(); + $this->removeDirectory($basePath); + } + } + #[Test] public function itParsesTheConfiguredParatestConfigurationFile(): void { @@ -466,4 +526,12 @@ public function cleanupTemporaryConfigurationFilePublic(): void { $this->cleanupTemporaryConfigurationFile(); } + + /** + * Expose environment cleanup. + */ + public function clearEnvPublic(): void + { + $this->clearEnv(); + } } diff --git a/tests/Testing/ParallelRunnerTest.php b/tests/Testing/ParallelRunnerTest.php index ffb2fe1af..9c33e3cf7 100644 --- a/tests/Testing/ParallelRunnerTest.php +++ b/tests/Testing/ParallelRunnerTest.php @@ -6,11 +6,18 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Foundation\Application; +use Hypervel\Support\Facades\ParallelTesting; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelRunner; +use ParaTest\Options; use PHPUnit\Framework\Attributes\Test; use ReflectionClass; use ReflectionMethod; +use RuntimeException; +use Symfony\Component\Console\Input\ArgvInput; +use Symfony\Component\Console\Input\InputDefinition; +use Symfony\Component\Console\Output\BufferedOutput; class ParallelRunnerTest extends TestCase { @@ -52,6 +59,87 @@ public function itCreatesTheApplicationFromTheInferredBasePath(): void } } + #[Test] + public function itResolvesProcessTokensAsStrings(): void + { + $tokens = []; + + ParallelRunner::resolveApplicationUsing(fn () => new Application($this->app->basePath())); + + $runner = new ParallelRunner($this->optionsWithProcesses(2), new BufferedOutput); + $method = new ReflectionMethod(ParallelRunner::class, 'forEachProcess'); + + try { + $method->invoke($runner, function () use (&$tokens): void { + $tokens[] = ParallelTesting::token(); + }); + } finally { + ParallelRunner::resolveApplicationUsing(null); + ParallelTesting::resolveTokenUsing(null); + } + + $this->assertSame(['1', '2'], $tokens); + } + + #[Test] + public function itRestoresTheAmbientTokenResolverAfterEachProcess(): void + { + $tokens = []; + $applications = [ + new ParallelRunnerFlushTrackingApplication($this->app->basePath()), + new ParallelRunnerFlushTrackingApplication($this->app->basePath()), + ]; + $previousServerToken = is_string($_SERVER['TEST_TOKEN'] ?? null) ? $_SERVER['TEST_TOKEN'] : null; + + $_SERVER['TEST_TOKEN'] = 'ambient'; + ParallelRunner::resolveApplicationUsing(static fn () => array_shift($applications)); + + $runner = new ParallelRunner($this->optionsWithProcesses(2), new BufferedOutput); + $method = new ReflectionMethod(ParallelRunner::class, 'forEachProcess'); + + try { + $method->invoke($runner, function () use (&$tokens): void { + $tokens[] = ParallelTesting::token(); + }); + + $this->assertSame(['1', '2'], $tokens); + $this->assertSame('ambient', ParallelTesting::token()); + } finally { + ParallelRunner::resolveApplicationUsing(null); + ParallelTesting::resolveTokenUsing(null); + $this->restoreServerTestToken($previousServerToken); + } + } + + #[Test] + public function itClearsTheTokenResolverAndFlushesTheApplicationWhenAProcessCallbackFails(): void + { + $application = new ParallelRunnerFlushTrackingApplication($this->app->basePath()); + $previousServerToken = is_string($_SERVER['TEST_TOKEN'] ?? null) ? $_SERVER['TEST_TOKEN'] : null; + + $_SERVER['TEST_TOKEN'] = 'ambient'; + ParallelRunner::resolveApplicationUsing(static fn () => $application); + + $runner = new ParallelRunner($this->optionsWithProcesses(1), new BufferedOutput); + $method = new ReflectionMethod(ParallelRunner::class, 'forEachProcess'); + + try { + $method->invoke($runner, static function (): never { + throw new RuntimeException('process callback failed'); + }); + + $this->fail('The process callback exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('process callback failed', $exception->getMessage()); + $this->assertTrue($application->flushed); + $this->assertSame('ambient', ParallelTesting::token()); + } finally { + ParallelRunner::resolveApplicationUsing(null); + ParallelTesting::resolveTokenUsing(null); + $this->restoreServerTestToken($previousServerToken); + } + } + /** * Restore the APP_BASE_PATH values. */ @@ -69,4 +157,50 @@ protected function restoreAppBasePath(): void $_SERVER['APP_BASE_PATH'] = $this->originalAppBasePathServer; } } + + /** + * Get ParaTest options with the given process count. + */ + protected function optionsWithProcesses(int $processes): Options + { + $inputDefinition = new InputDefinition; + Options::setInputDefinition($inputDefinition); + + return Options::fromConsoleInput( + new ArgvInput([ + 'paratest', + '--configuration=' . dirname(__DIR__, 2) . '/phpunit.xml.dist', + '--runner=' . ParallelRunner::class, + '--processes=' . $processes, + ], $inputDefinition), + dirname(__DIR__, 2), + ); + } + + /** + * Restore the TEST_TOKEN server value. + */ + protected function restoreServerTestToken(?string $token): void + { + if ($token === null) { + unset($_SERVER['TEST_TOKEN']); + } else { + $_SERVER['TEST_TOKEN'] = $token; + } + } +} + +class ParallelRunnerFlushTrackingApplication extends Application +{ + public bool $flushed = false; + + /** + * Flush the container of all bindings and resolved instances. + */ + public function flush(): void + { + $this->flushed = true; + + parent::flush(); + } }