diff --git a/AGENTS.md b/AGENTS.md index fb1280d04..efa1d72c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -576,7 +576,11 @@ Examples: `tests/Inertia/CoroutineIsolationTest.php`, `tests/Container/Coroutine When porting source classes that use static properties for caching (e.g., `$booted`, `$globalScopes`, resolved config values, compiled formats): 1. Add a `public static function flushState(): void` method that resets the static properties to their initial values -2. Check whether the subscriber (`tests/AfterEachTestSubscriber.php`) should call it — if the cached state could leak between tests and cause failures, add the call +2. Check whether the subscriber (`src/testing/src/PHPUnit/AfterEachTestSubscriber.php`) should call it — if the cached state could leak between tests and cause failures, add the call + +Framework-owned classes go in `AfterEachTestSubscriber`. First-party optional framework packages may stay in grouped optional methods at the bottom of that subscriber. Third-party packages, private packages, and applications should register their cleanup through `extra.hypervel.test-state` and a `TestState` registrar instead of hardcoding their classes into the framework subscriber. + +Do not add `Hypervel\Testing\PHPUnit\AfterEachTestCleanup` itself to `AfterEachTestSubscriber`. Its callbacks are suite-level registrations that must persist for the PHPUnit worker lifetime. Place `flushState()` at the end of the class. The only exception is when the class has trailing magic dispatch/lifecycle methods (`__call`, `__callStatic`, `__get`, `__set`, `__isset`, `__unset`, `__destruct`) at the end; in that case, place `flushState()` immediately before that trailing magic-method block. `__invoke()` is not a placement anchor. diff --git a/docs/plans/2026-07-04-testing-after-each-cleanup-registrars.md b/docs/plans/2026-07-04-testing-after-each-cleanup-registrars.md new file mode 100644 index 000000000..fff545710 --- /dev/null +++ b/docs/plans/2026-07-04-testing-after-each-cleanup-registrars.md @@ -0,0 +1,1133 @@ +# Testing After Each Cleanup Registrars Plan + +Date: 2026-07-04 + +Author: Codex + +Scope: make Hypervel's after-each test cleanup extensible for apps and packages while keeping framework cleanup centralized, deterministic, and independent of application boot. + +## Goal + +Build a first-class test cleanup API for Hypervel's long-lived worker model: + +- Keep the framework-owned after-each cleanup list centralized in `hypervel/testing`. +- Let apps register their own worker-lifetime/static cleanup in one obvious generated class. +- Let packages ship their own cleanup registrars so app developers do not need to know package internals. +- Make cleanup registration work from the first test in every PHPUnit or Paratest worker, including workers that run only app-less unit tests. +- Avoid service provider based registration, separate local PHPUnit subscribers, duplicated Composer manifest parsing, stale docs, and any code that suggests cleanup is optional for worker-lifetime state. +- Fix the app skeleton so generated apps run Hypervel's framework cleanup after every test. + +Churn and backwards compatibility do not constrain this plan. The resulting codebase should read as if the testing cleanup API was designed this way from the start. + +## Source Material Reviewed + +Project instructions: + +- `contrib/hypervel/components/AGENTS.md`, read in full on 2026-07-04 before writing this plan. + +Hypervel source and docs: + +- `src/testing/src/PHPUnit/AfterEachTestExtension.php` +- `src/testing/src/PHPUnit/AfterEachTestSubscriber.php` +- `src/testing/composer.json` +- `src/foundation/src/PackageManifest.php` +- `src/testbench/src/Foundation/PackageManifest.php` +- `src/boost/docs/testing.md` +- `src/boost/docs/packages.md` +- `phpunit.xml.dist` +- `tests/Testing/TestingStaticStateTest.php` +- `tests/Foundation/FoundationPackageManifestTest.php` +- `tests/Foundation/Fixtures/composer.json` +- `tests/Foundation/Fixtures/vendor/composer/installed.json` + +Application skeleton: + +- `../hypervel/phpunit.xml` +- `../hypervel/composer.json` +- `../hypervel/tests/TestCase.php` +- `../hypervel/tests/Feature/ExampleTest.php` +- `../hypervel/tests/Unit/ExampleTest.php` + +Codesonic consensus messages: + +- `.codesonic/agents/messages/2026-07-04-235259-codex-to-claude-second-opinion-testing-after-each-cleanup-extension-point-for-apps-and-packages.md` +- `.codesonic/agents/messages/2026-07-05-000034-claude-to-codex-second-opinion-testing-after-each-cleanup-extension-point-for-apps-and-packages.md` +- `.codesonic/agents/messages/2026-07-05-001905-claude-to-codex-second-opinion-full-cleanup-api-with-package-owned-automatic-registration.md` +- `.codesonic/agents/messages/2026-07-05-003017-claude-to-codex-second-opinion-full-cleanup-api-with-package-owned-automatic-registration.md` +- `.codesonic/agents/messages/2026-07-05-003236-codex-to-claude-second-opinion-full-cleanup-api-with-package-owned-automatic-registration.md` +- `.codesonic/agents/messages/2026-07-05-003409-claude-to-codex-second-opinion-full-cleanup-api-with-package-owned-automatic-registration.md` + +## Current State + +### Framework Cleanup + +`src/testing/src/PHPUnit/AfterEachTestExtension.php` currently only registers the subscriber: + +```php +class AfterEachTestExtension implements Extension +{ + /** + * Bootstrap the extension. + */ + public function bootstrap(Configuration $configuration, Facade $facade, ParameterCollection $parameters): void + { + $facade->registerSubscriber(new AfterEachTestSubscriber); + } +} +``` + +`src/testing/src/PHPUnit/AfterEachTestSubscriber.php` has a single large `notify()` method. It runs: + +- `Mockery::close()`; +- Carbon, container, coroutine context, model, routing, view, support, testing, and other framework static cleanup; +- optional first-party package cleanup methods at the bottom through `callIfExists()`. + +The optional first-party cleanup methods are intentionally grouped at the bottom: + +```php +$this->flushFortifyState(); +$this->flushHorizonState(); +$this->flushInertiaState(); +$this->flushNestedSetState(); +$this->flushPasskeysState(); +$this->flushPermissionState(); +$this->flushReverbState(); +$this->flushSanctumState(); +$this->flushScoutState(); +$this->flushSentryState(); +$this->flushTelescopeState(); +$this->flushTestbenchState(); +$this->flushWayfinderState(); +``` + +This works for framework-owned classes and first-party optional packages that the framework knows about. It does not give third-party packages or apps a clean, automatic place to register their own static cleanup. + +### Package Manifest + +`src/foundation/src/PackageManifest.php` currently mixes two responsibilities inside `build()`: + +1. Scan `vendor/composer/installed.json`, read each package's `extra.hypervel` metadata, merge `dont-discover`, and append package `version`. +2. Write the resulting manifest to disk. + +The scan details currently live only inside `build()`: + +```php +$packages = []; + +if ($this->files->exists($path = $this->vendorPath . '/composer/installed.json')) { + $installed = json_decode($this->files->get($path), true); + + $packages = $installed['packages'] ?? $installed; +} + +$ignore = $this->packagesToIgnore(); + +$manifest = (new Collection($packages))->mapWithKeys(function (array $package) { + return [$this->format($package['name']) => [ + ...($package['extra']['hypervel'] ?? []), + 'version' => $package['version'] ?? null, + ]]; +})->each(function (array $configuration) use (&$ignore) { + $ignore = array_merge($ignore, $configuration['dont-discover'] ?? []); +})->reject(function (array $configuration, string $package) use ($ignore) { + return in_array($package, $ignore, true); +})->filter()->all(); +``` + +The testing package needs the scan result at PHPUnit extension bootstrap, but it must not call `build()` because `build()` writes to `bootstrap/cache`. Package test repos and partial installs may not have that directory or may not want writes during extension bootstrap. + +### Application Skeleton + +`contrib/hypervel/hypervel/phpunit.xml` currently has: + +```xml +bootstrap="vendor/autoload.php" +``` + +It has no `` block and does not register `Hypervel\Testing\PHPUnit\AfterEachTestExtension`. Apps generated from the skeleton therefore run zero framework static-state cleanup between tests. Framework statics such as the container instance, coroutine context, Carbon test time, model caches, macros, and facade resolved instances can leak across an app test suite. + +The components repo and the private package repos already register `AfterEachTestExtension` in `phpunit.xml.dist`. The missing artifact is the app skeleton. + +### Documentation + +`src/boost/docs/testing.md` currently tells users to manually flush `Collection` macros in `tearDown()`: + +```php +protected function tearDown(): void +{ + Collection::flushMacros(); + + parent::tearDown(); +} +``` + +That advice is stale for framework classes. `Hypervel\Support\Collection::flushState()` already clears macros and proxies, and `AfterEachTestSubscriber` already calls `\Hypervel\Support\Collection::flushState()`. The docs should instead explain: + +- framework cleanup is automatic when the testing extension is registered; +- app-owned or package-owned macroable/static state should be registered through the new cleanup API; +- app developers should not duplicate cleanup for framework classes already handled by Hypervel. + +## Decisions + +### Use A Registry For After-Each Cleanup Callbacks + +Add `Hypervel\Testing\PHPUnit\AfterEachTestCleanup`. + +The registry owns callbacks that run after every test. Registrations are process-local and persist for the PHPUnit worker lifetime. It is a test bootstrap API, not application runtime API. + +Proposed shape: + +```php + + */ + protected static array $callbacks = []; + + /** + * Register a callback to flush test state after every test. + * + * Boot or tests only. The callback persists in static state for the + * PHPUnit worker lifetime and runs after every subsequent test. + */ + public static function flushUsing(string $name, callable $callback): void + { + static::$callbacks[$name] = Closure::fromCallable($callback); + } + + /** + * Run registered callbacks. + * + * @throws Throwable + */ + public static function runCallbacks(): void + { + /** @var Throwable|null $exception */ + $exception = null; + + foreach (static::$callbacks as $callback) { + try { + $callback(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; + } + } + + /** + * Forget all registered callbacks. + * + * Boot or tests only. This removes process-local cleanup registrations + * for the current PHPUnit worker, including callbacks discovered from + * app and package metadata. + */ + public static function forgetCallbacks(): void + { + static::$callbacks = []; + } +} +``` + +Important details: + +- The name is required. This makes registration idempotent by construction and avoids append-only growth if a registrar runs more than once. +- Package-owned names should be Composer package names such as `vendor/package`. +- App-owned names may use `app`. +- Last registration for a name wins. +- Unique root app callbacks register after package callbacks, so they run after package callbacks and before framework cleanup. +- `runCallbacks()` continues through all callbacks if one throws, then rethrows the first exception. This prevents one broken package cleanup callback from suppressing another package's cleanup. +- Do not add `AfterEachTestCleanup` to the framework subscriber's flush list. Calling `forgetCallbacks()` after every test would erase suite-level registrations and silently disable app/package cleanup after the first test. Add a short class-level comment explaining that this class intentionally does not expose `flushState()` and should not be included in `AfterEachTestSubscriber`. + +### Run Custom Cleanup Before Framework Cleanup + +Refactor `AfterEachTestSubscriber` so `notify()` delegates to a method that can be tested directly: + +```php +/** + * Clean up static state after a test finishes. + */ +public function notify(Finished $event): void +{ + $this->flushStateAfterTest(); +} + +/** + * Flush all test state after a test finishes. + */ +public function flushStateAfterTest(): void +{ + try { + AfterEachTestCleanup::runCallbacks(); + } finally { + $this->flushFrameworkState(); + } +} + +/** + * Flush framework-owned static state. + */ +protected function flushFrameworkState(): void +{ + // Existing notify() body moves here. +} +``` + +Custom callbacks run before framework cleanup because app/package cleanup may need framework services, facades, the container, context, or other framework state while cleaning itself up. The framework cleanup then gets the last pass. + +If a custom callback throws and framework cleanup also throws inside the `finally`, PHP will surface the framework cleanup exception. That double-fault behavior is acceptable because framework cleanup must still be attempted, and a framework cleanup failure is itself a suite-level failure that needs fixing. + +Keep the optional first-party package cleanup methods together at the bottom of `flushFrameworkState()`, preserving the current layout. + +### Discover Package And App Registrars At PHPUnit Extension Bootstrap + +Do not register package cleanup from service providers. + +Provider-based registration fails in normal Paratest scenarios: + +- `Hypervel\Tests\TestCase` unit tests do not boot an application. +- A Paratest worker can receive only app-less unit test files. +- In that worker, package providers never run. +- Any provider-registered cleanup would be missing until an app-booting test happens to run in the same worker. + +Package cleanup must be discovered and registered by `AfterEachTestExtension::bootstrap()` because the PHPUnit extension runs at worker bootstrap before the first test, independent of application boot. + +Add a small discoverer class in the testing package, for example: + +```php +files = $files ?? new Filesystem; + } + + /** + * Create a discoverer for the current Composer root install. + */ + public static function forRootInstall(): static + { + $basePath = static::installedRootPath(); + + return new static( + $basePath, + Env::get('COMPOSER_VENDOR_DIR') ?: $basePath . '/vendor' + ); + } + + /** + * Register discovered test-state registrars. + */ + public function register(): void + { + foreach ($this->registrars() as $source => $classes) { + foreach ((array) $classes as $class) { + $this->registerClass($source, $class); + } + } + } + + /** + * Get root test-state registrar classes. + * + * @return array + */ + protected function rootRegistrars(): array + { + $path = $this->basePath . '/composer.json'; + + if (! $this->files->isFile($path)) { + return []; + } + + $composer = json_decode( + $this->files->get($path), + true + ); + + if (! is_array($composer)) { + return []; + } + + return (array) ($composer['extra']['hypervel']['test-state'] ?? []); + } + + /** + * Get registrar classes keyed by their source package. + * + * @return array> + */ + protected function registrars(): array + { + $registrars = []; + $baseIgnore = PackageManifest::packagesToIgnoreFromComposer($this->files, $this->basePath); + + foreach (PackageManifest::discoverInstalledPackages($this->files, $this->vendorPath, $baseIgnore) as $package => $configuration) { + if (isset($configuration['test-state'])) { + $registrars[$package] = (array) $configuration['test-state']; + } + } + + $rootRegistrars = $this->rootRegistrars(); + + if ($rootRegistrars !== []) { + $registrars['root'] = $rootRegistrars; + } + + return $registrars; + } + + /** + * Register one registrar class. + */ + protected function registerClass(string $source, string $class): void + { + if (! class_exists($class)) { + throw new RuntimeException( + "Test-state registrar [{$class}] declared by [{$source}] does not exist. Check that it is in normal autoload, not dependency autoload-dev." + ); + } + + if (! method_exists($class, 'register')) { + throw new RuntimeException( + "Test-state registrar [{$class}] declared by [{$source}] must define a register method." + ); + } + + $class::register(); + } +} +``` + +The implementation should include the needed root path helper. The vendor path must be computed the same way as `Hypervel\Foundation\PackageManifest`, honoring `COMPOSER_VENDOR_DIR` before falling back to `$basePath . '/vendor'`; do not derive a separate vendor path by reflecting Composer internals. + +```php +/** + * Get the Composer root package path. + */ +protected static function installedRootPath(): string +{ + $rootPackage = InstalledVersions::getRootPackage(); + $basePath = $rootPackage['install_path'] ?? getcwd(); + + if (! is_string($basePath) || $basePath === '') { + $currentPath = getcwd(); + $basePath = $currentPath === false ? '.' : $currentPath; + } + + $realPath = realpath($basePath); + + return $realPath === false ? $basePath : $realPath; +} +``` + +The implementation must satisfy static analysis. If a value has already been proven locally but phpstan cannot infer it, use clear local structure or a local `@var` assertion rather than defensive runtime clutter. + +Register the discoverer from the extension: + +```php +public function bootstrap(Configuration $configuration, Facade $facade, ParameterCollection $parameters): void +{ + TestStateRegistrars::forRootInstall()->register(); + + $facade->registerSubscriber(new AfterEachTestSubscriber); +} +``` + +Discovery order is stable: + +1. Package registrars in the order returned by Composer's `installed.json` after `dont-discover` filtering. +2. Root app/package registrars from root `composer.json` last. + +Callbacks registered by root app registrars therefore run after package callbacks when they use unique names. Duplicate callback names are last-wins; use package-scoped names to avoid accidental replacement. + +### Use `extra.hypervel.test-state` + +Packages declare registrar classes in their normal package `composer.json` metadata: + +```json +{ + "autoload": { + "psr-4": { + "Vendor\\Package\\": "src/" + } + }, + "extra": { + "hypervel": { + "providers": [ + "Vendor\\Package\\PackageServiceProvider" + ], + "test-state": [ + "Vendor\\Package\\Testing\\TestState" + ] + } + } +} +``` + +Package registrars must live in the package's normal source autoload, not `autoload-dev`, because consuming apps do not load dependency dev autoloaders. + +A package registrar should aggregate that package's cleanup: + +```php + static::flushState()); + } + + /** + * Flush package static state. + */ + public static function flushState(): void + { + InvoiceNumbers::flushState(); + TaxRates::flushState(); + ReceiptMacros::flushState(); + } +} +``` + +For monopackages with many internal sub-packages, use one registrar class and one Composer metadata entry. The registrar may internally group optional cleanup: + +```php +public static function flushState(): void +{ + static::flushCoreState(); + static::flushAuthState(); + static::flushBillingState(); +} + +protected static function flushBillingState(): void +{ + static::callIfExists(BillingManager::class, 'flushState'); +} +``` + +Optional checks belong inside the owning registrar. The discovery layer only validates the declared registrar class itself. + +Root apps and root package repos declare their own registrars in root `composer.json`: + +```json +"extra": { + "hypervel": { + "dont-discover": [], + "test-state": [ + "Tests\\Support\\TestState" + ] + } +} +``` + +The root class may live under root `autoload-dev` because the root dev autoload is loaded during tests: + +```php + static::flushState()); + } + + /** + * Flush application static state. + */ + public static function flushState(): void + { + // + } +} +``` + +### Declare Direct Testing Package Dependencies + +Update `src/testing/composer.json` to require `hypervel/filesystem` directly. + +The testing package already imports filesystem classes indirectly through existing cleanup calls, and the registrar discoverer will use `Hypervel\Filesystem\Filesystem` directly. `hypervel/testing` currently receives filesystem transitively through `hypervel/foundation`; the final package metadata should declare its direct imports instead of relying on transitive resolution. + +The monorepo root `composer.json` already replaces `hypervel/filesystem` and `hypervel/testing`, so the split package composer file is the important dependency surface for this change. + +### Fail Fast Only For Declared Broken Registrars + +The discovery layer should distinguish missing metadata files from broken declared metadata: + +- Missing root `composer.json` or missing `vendor/composer/installed.json`: degrade to framework cleanup only. +- Malformed root `composer.json` or malformed `vendor/composer/installed.json`: degrade to framework cleanup only without emitting PHP warnings from chained array access. +- A value named in `extra.hypervel.test-state` that is not a class name string: throw at PHPUnit bootstrap with the source package. +- A class named in `extra.hypervel.test-state` that does not exist: throw at PHPUnit bootstrap with the class and source package. +- A class named in `extra.hypervel.test-state` that lacks `register()`: throw at PHPUnit bootstrap with the class and source package. +- A non-static `register()` method may fail naturally when called statically. Reflection is not required unless the implementation chooses to improve the error message without adding clutter. + +This keeps partial package repos and CI states resilient while making real package/app metadata bugs obvious. + +### Extract No-Write Package Discovery From `PackageManifest` + +Add a pure helper to `Hypervel\Foundation\PackageManifest`: + +```php +/** + * Discover installed Hypervel package metadata. + * + * @param array $baseIgnore + */ +public static function discoverInstalledPackages(Filesystem $files, string $vendorPath, array $baseIgnore): array +{ + $packages = []; + + if ($files->exists($path = $vendorPath . '/composer/installed.json')) { + $installed = json_decode($files->get($path), true); + + if (is_array($installed)) { + $packages = $installed['packages'] ?? $installed; + } + } + + $ignore = $baseIgnore; + + return (new Collection($packages))->mapWithKeys(function (array $package) use ($vendorPath) { + return [static::formatPackageName($package['name'], $vendorPath) => [ + ...($package['extra']['hypervel'] ?? []), + 'version' => $package['version'] ?? null, + ]]; + })->each(function (array $configuration) use (&$ignore) { + $ignore = array_merge($ignore, $configuration['dont-discover'] ?? []); + })->reject(function (array $configuration, string $package) use ($ignore) { + return in_array($package, $ignore, true); + })->filter()->all(); +} +``` + +Then make `build()` a write-through caller: + +```php +/** + * Build the manifest and write it to disk. + */ +public function build(): void +{ + $manifest = static::discoverInstalledPackages( + $this->files, + $this->vendorPath, + $this->packagesToIgnore() + ); + + $this->write($manifest); + + $this->manifest = $manifest; + $this->rawManifest = $manifest; +} +``` + +The helper must keep `build()` output byte-identical for existing fixtures: + +- same `vendor/composer/installed.json` handling; +- same `$installed['packages'] ?? $installed`; +- same root `extra.hypervel.dont-discover`; +- same per-package `dont-discover` merge before filtering; +- same package name formatting; +- same `version` key appended to every package configuration; +- same inclusion of version-only packages unless they are ignored. +- same virtual `packagesToIgnore()` dispatch from `build()`. +- malformed JSON handled without PHP warnings. + +The static helper must take the initial ignore list as an argument. Do not make it read root `composer.json` directly. `Hypervel\Testbench\Foundation\PackageManifest` overrides `packagesToIgnore()` to return `[]` during build so testbench can manage package ignores at read time. `build()` must keep calling `$this->packagesToIgnore()` and pass that result into the static helper, preserving testbench's override. + +Add helper methods only where they remove real duplication: + +```php +/** + * Format the given package name. + */ +protected static function formatPackageName(string $package, string $vendorPath): string +{ + return str_replace($vendorPath . '/', '', $package); +} + +/** + * Get the package names ignored by root composer metadata. + * + * @return array + */ +public static function packagesToIgnoreFromComposer(Filesystem $files, string $basePath): array +{ + if (! $files->isFile($basePath . '/composer.json')) { + return []; + } + + $composer = json_decode($files->get( + $basePath . '/composer.json' + ), true); + + if (! is_array($composer)) { + return []; + } + + return $composer['extra']['hypervel']['dont-discover'] ?? []; +} +``` + +Keep the existing `format()` instance method because `Hypervel\Testbench\Foundation\PackageManifest::providersFromRoot()` calls it. Update `format()` to delegate to `static::formatPackageName($package, $this->vendorPath)` if useful, but do not remove it. + +Keep the existing `packagesToIgnore()` instance method as the virtual seam that `build()` calls. It may delegate to `static::packagesToIgnoreFromComposer($this->files, $this->basePath)`, but it must remain overridable so testbench's override keeps working. + +### Update The Application Skeleton + +Update `contrib/hypervel/hypervel/phpunit.xml` to register the after-each cleanup extension: + +```xml + + + +``` + +Do not replace `bootstrap="vendor/autoload.php"` with a custom `tests/bootstrap.php`. Composer root metadata discovery means apps do not need another bootstrap file just to register cleanup. + +Update `contrib/hypervel/hypervel/composer.json`: + +```json +"extra": { + "hypervel": { + "dont-discover": [], + "test-state": [ + "Tests\\Support\\TestState" + ] + } +} +``` + +Add `contrib/hypervel/hypervel/tests/Support/TestState.php`: + +```php + static::flushState()); + } + + /** + * Flush application static state. + */ + public static function flushState(): void + { + // + } +} +``` + +This class is intentionally empty in the skeleton. It gives app developers a wired place to add cleanup without exposing PHPUnit extension internals. + +The app skeleton currently also does not enable `SlowTestExtension`. That is a separate skeleton policy decision, not part of state cleanup correctness. During implementation, surface that decision to the owner before making any slow-test skeleton change. + +### Update Documentation + +Update `src/boost/docs/testing.md`. + +Add a "Test State Cleanup" section near "Running Tests in Coroutines" and before "Macro State": + +~~~md + +### Test State Cleanup + +Hypervel applications keep framework objects, static caches, macros, and manager state in memory for the life of the PHP process. During tests, Hypervel's PHPUnit extension flushes framework-owned state after every test method. + +If your application has its own worker-lifetime state, add its cleanup to `tests/Support/TestState.php`. Use this class as one application-level entry point that aggregates the cleanup for any stateful classes your app owns: + +```php +namespace Tests\Support; + +use Hypervel\Testing\PHPUnit\AfterEachTestCleanup; + +class TestState +{ + public static function register(): void + { + AfterEachTestCleanup::flushUsing('app', fn () => static::flushState()); + } + + public static function flushState(): void + { + InvoiceNumbers::flushState(); + TaxRates::flushState(); + ReceiptMacros::flushState(); + } +} +``` + +Callbacks registered by your app run after package cleanup callbacks and before Hypervel flushes framework state. This means framework services are still available while your callback runs, then Hypervel tears them down immediately after. + +Do not call `AfterEachTestCleanup::forgetCallbacks()` from ordinary application tests. That method clears all registered callbacks for the current PHPUnit worker, including callbacks discovered from application and package metadata. +~~~ + +Document package authors in `src/boost/docs/packages.md` because `extra.hypervel.test-state` is package metadata. Place this as a `### Test State Cleanup` section near package discovery metadata and add it to the page's section list. + +~~~md + +### Test State Cleanup + +Packages that keep worker-lifetime state for tests may declare a test-state registrar: + +```json +"extra": { + "hypervel": { + "test-state": [ + "Vendor\\Package\\Testing\\TestState" + ] + } +} +``` + +The registrar must be autoloadable from the package's normal `autoload` section and must define `register()`. Use the registrar as one package-level entry point that aggregates the cleanup for any stateful classes your package owns: + +```php +namespace Vendor\Package\Testing; + +use Hypervel\Testing\PHPUnit\AfterEachTestCleanup; + +class TestState +{ + public static function register(): void + { + AfterEachTestCleanup::flushUsing('vendor/package', fn () => static::flushState()); + } + + public static function flushState(): void + { + InvoiceNumbers::flushState(); + TaxRates::flushState(); + ReceiptMacros::flushState(); + } +} +``` + +Use your Composer package name as the callback name. Registrar classes are discovered during PHPUnit extension bootstrap, so package cleanup runs even in workers that only execute unit tests and never boot a Hypervel application. +~~~ + +Replace the current `Collection::flushMacros()` example in "Macro State" with app-owned macroable guidance: + +```md +Hypervel already flushes framework macroable classes such as `Collection`, `ResponseFactory`, `View\Factory`, and testing helpers after every test. Do not add teardown cleanup for framework classes already handled by Hypervel. + +If your application or package defines its own macroable class and registers temporary macros inside a test, add that class to your test-state cleanup. +``` + +Also update the table of contents. + +### Update AGENTS.md If Implementation Changes The Pattern + +After implementation, update `contrib/hypervel/components/AGENTS.md` only where its current guidance becomes stale. + +The current "Static State and Test Cleanup" section says: + +- `AfterEachTestSubscriber` handles global cleanup. +- When porting source classes that use static properties, add `flushState()`. +- Check whether the subscriber should call it. + +After the new API exists, keep the framework guidance but add package/app guidance: + +- framework-owned classes still go in `AfterEachTestSubscriber`; +- first-party optional framework packages may remain in grouped optional methods at the bottom of the subscriber; +- third-party packages and app/private package repos should use `extra.hypervel.test-state` and `TestState` registrars; +- do not add `AfterEachTestCleanup` itself to the subscriber's flush list. + +This prevents future contributors from hardcoding private/third-party cleanup into the framework subscriber. + +## Implementation Steps + +1. Read current `AfterEachTestExtension`, `AfterEachTestSubscriber`, `PackageManifest`, app skeleton `phpunit.xml`, app skeleton `composer.json`, `testing.md`, and `packages.md` before editing. +2. Add `src/testing/src/PHPUnit/AfterEachTestCleanup.php`. +3. Add tests for `AfterEachTestCleanup` under `tests/Testing/PHPUnit/AfterEachTestCleanupTest.php`. +4. Refactor `AfterEachTestSubscriber`: + - `notify()` delegates to `flushStateAfterTest()`; + - `flushStateAfterTest()` runs custom callbacks before framework cleanup; + - existing framework cleanup body moves to `flushFrameworkState()`; + - optional package methods stay grouped at the bottom. +5. Add tests for subscriber callback ordering and failure behavior under `tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php`. +6. Add `hypervel/filesystem` as a direct dependency in `src/testing/composer.json`. +7. Extract `PackageManifest::discoverInstalledPackages()`. +8. Update `PackageManifest::build()` to call the helper through `$this->packagesToIgnore()` and keep cached output identical. +9. Add/adjust Foundation and Testbench package manifest tests for the pure helper, byte-identical build output, and preserved testbench ignore behavior. +10. Add `src/testing/src/PHPUnit/TestStateRegistrars.php` or equivalent discoverer. +11. Update `AfterEachTestExtension` to run registrar discovery before registering the subscriber. +12. Add tests for registrar discovery: + - package registrars from `extra.hypervel.test-state`; + - root registrars from root `composer.json`; + - package registrars run before root registrars; + - missing `composer.json` and missing `installed.json` degrade cleanly; + - declared missing registrar class throws a clear exception; + - declared registrar without `register()` throws a clear exception; + - duplicate callback names are idempotent and last-wins. +13. Update app skeleton: + - add `AfterEachTestExtension` to `phpunit.xml`; + - add root `extra.hypervel.test-state`; + - add `tests/Support/TestState.php`. +14. Surface the `SlowTestExtension` skeleton decision to the owner before making any slow-test skeleton change. +15. Update `src/boost/docs/testing.md`. +16. Update `src/boost/docs/packages.md`. +17. Update `AGENTS.md` cleanup guidance and correct the stale `tests/AfterEachTestSubscriber.php` path to `src/testing/src/PHPUnit/AfterEachTestSubscriber.php`. +18. Run focused tests after each changed test file. +19. Run the app skeleton test command from `contrib/hypervel/hypervel` after changing the skeleton. +20. Run `composer fix` from `contrib/hypervel/components`. +21. Do a full self-review of all changes, tracing discovery, registration, callback execution, manifest scanning, skeleton config, and docs. +22. Request code review from Claude and loop until signoff. + +## Testing Plan + +### AfterEachTestCleanup + +Create `tests/Testing/PHPUnit/AfterEachTestCleanupTest.php`. + +The test class should call `AfterEachTestCleanup::forgetCallbacks()` in `tearDown()` because the registry intentionally persists for the PHPUnit worker lifetime. + +Assertions: + +- `flushUsing()` registers callbacks by name. +- `runCallbacks()` executes callbacks in registration order. +- callbacks persist across multiple `runCallbacks()` calls. +- registering the same name replaces the callback. +- unique root/app callback registered after package callback runs after package callback. +- `forgetCallbacks()` clears all callbacks. +- if one callback throws, later callbacks still run and the first exception is rethrown. + +Run immediately after creating the file: + +```shell +cd contrib/hypervel/components +./vendor/bin/phpunit --no-progress tests/Testing/PHPUnit/AfterEachTestCleanupTest.php +``` + +### AfterEachTestSubscriber + +Create `tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php`. + +The test class should call `AfterEachTestCleanup::forgetCallbacks()` in `tearDown()` and reset any framework state it mutates if the assertion fails before the subscriber cleanup runs. + +Assertions: + +- `flushStateAfterTest()` runs custom callbacks before framework cleanup. +- framework cleanup still runs when a custom callback throws. +- the thrown callback exception still surfaces after cleanup. + +Do not call the real `flushFrameworkState()` from this test. It clears the container instance, coroutine context, facade resolved instances, and other state that the test process itself is using. Instead, test ordering and `finally` behavior with an anonymous subclass: + +```php +$subscriber = new class extends AfterEachTestSubscriber { + /** + * The observed cleanup order. + * + * @var array + */ + public array $order = []; + + /** + * Flush framework-owned static state. + */ + protected function flushFrameworkState(): void + { + $this->order[] = 'framework'; + } +}; + +AfterEachTestCleanup::flushUsing('probe', function () use ($subscriber): void { + $subscriber->order[] = 'custom'; +}); + +$subscriber->flushStateAfterTest(); + +$this->assertSame(['custom', 'framework'], $subscriber->order); +``` + +Do not construct PHPUnit's `Finished` event; test the direct method. The real `flushFrameworkState()` body is exercised by the global subscriber during the suite. + +Run immediately: + +```shell +cd contrib/hypervel/components +./vendor/bin/phpunit --no-progress tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php +``` + +### PackageManifest + +Extend `tests/Foundation/FoundationPackageManifestTest.php`. + +Assertions: + +- `PackageManifest::discoverInstalledPackages(new Filesystem, $vendorPath, $baseIgnore)` returns the same manifest array that `build()` writes for the existing fixture when `$baseIgnore` comes from root composer metadata. +- the helper handles Composer 2 object format using the current fixture. +- add a small bare-array `installed.json` fixture or temporary file if no existing test covers legacy bare-array format. +- root `dont-discover` and package `dont-discover` still filter the same packages. +- version keys remain present. +- version-only packages remain present unless filtered, matching current behavior. +- missing `installed.json` returns `[]` and does not write. +- malformed `installed.json` returns `[]` without PHP warnings. +- malformed root `composer.json` yields an empty base ignore list without PHP warnings. +- `Hypervel\Testbench\Foundation\PackageManifest` still bypasses root `dont-discover` during build through its `packagesToIgnore()` override. +- `Hypervel\Testbench\Foundation\PackageManifest::providersFromRoot()` still works because `format()` remains available. + +Run immediately: + +```shell +cd contrib/hypervel/components +./vendor/bin/phpunit --no-progress tests/Foundation/FoundationPackageManifestTest.php +``` + +### TestStateRegistrars + +Create `tests/Testing/PHPUnit/TestStateRegistrarsTest.php`. + +The test class should call `AfterEachTestCleanup::forgetCallbacks()` in `tearDown()` because registrar tests intentionally mutate the cleanup registry. + +Use temporary Composer roots under `ParallelTesting::tempDir()` rather than writing to committed fixtures, unless a small static fixture is clearer and read-only. Construct `TestStateRegistrars` directly with the temp root and temp vendor path; do not rely on `InstalledVersions::getRootPackage()` in unit tests. + +The test root should contain: + +- root `composer.json`; +- `vendor/composer/installed.json`; +- fixture registrar classes autoloadable from the test suite. + +Assertions: + +- package registrar classes listed in package `extra.hypervel.test-state` have `register()` called. +- root `extra.hypervel.test-state` registrar is called after package registrars. +- package `dont-discover` suppresses package test-state discovery consistently with provider discovery. +- root `dont-discover` suppresses package test-state discovery but not root test-state. +- missing `composer.json` does not throw. +- missing `vendor/composer/installed.json` does not throw. +- malformed root `composer.json` and malformed `vendor/composer/installed.json` do not emit PHP warnings. +- missing declared registrar class throws a `RuntimeException` naming the class and source package. +- declared registrar class without `register()` throws a `RuntimeException` naming the class and source package. +- `forRootInstall()` honors `COMPOSER_VENDOR_DIR`. + +Run immediately: + +```shell +cd contrib/hypervel/components +./vendor/bin/phpunit --no-progress tests/Testing/PHPUnit/TestStateRegistrarsTest.php +``` + +### Application Skeleton + +The app skeleton is a sibling repository at `contrib/hypervel/hypervel`, not the committed testbench skeleton under `src/testbench/hypervel`. Do not add a components test that depends on a sibling checkout being present in upstream CI. + +Verify the skeleton directly after updating it: + +```shell +cd contrib/hypervel/hypervel +composer test +``` + +Also inspect these files during self-review: + +- `phpunit.xml` contains `Hypervel\Testing\PHPUnit\AfterEachTestExtension`; +- root `composer.json` lists `Tests\\Support\\TestState` under `extra.hypervel.test-state`; +- `tests/Support/TestState.php` exists and registers the `app` callback. + +### Documentation And Static Analysis + +After code and docs are updated: + +```shell +cd contrib/hypervel/components +composer fix +``` + +`composer fix` runs php-cs-fixer, phpstan, and the parallel test suite in the configured order. + +## Self-Review Checklist + +Before requesting code review: + +- Re-read `AfterEachTestCleanup` and confirm it does not have `flushState()` and is not called by the subscriber's framework cleanup list. +- Trace `AfterEachTestExtension::bootstrap()` and confirm registrar discovery happens before the subscriber is registered. +- Trace `TestStateRegistrars` with: + - normal app root; + - package repo root; + - missing root `composer.json`; + - missing `vendor/composer/installed.json`; + - bad declared registrar class; + - class with missing `register()`; + - duplicate callback names. +- Trace `PackageManifest::build()` and verify the cache array is unchanged for existing fixtures. +- Confirm package discovery and provider/alias discovery still use one owner for installed package scan semantics. +- Confirm root `test-state` is read separately from installed package metadata. +- Confirm `dont-discover` affects package registrars consistently with providers and aliases. +- Confirm root `test-state` still registers even when root `dont-discover` disables all package discovery. +- Confirm custom callbacks run before framework cleanup and framework cleanup still runs when a custom callback throws. +- Confirm docs do not tell users to manually flush framework macro state in `tearDown()`. +- Confirm app skeleton tests use the generated `Tests\Support\TestState` class and no custom `tests/bootstrap.php`. +- Confirm no stale comments, workaround language, old API names such as `test-cleanup`, or dead helpers remain. diff --git a/src/boost/docs/packages.md b/src/boost/docs/packages.md index 320b61709..b278849a7 100644 --- a/src/boost/docs/packages.md +++ b/src/boost/docs/packages.md @@ -3,6 +3,7 @@ - [Introduction](#introduction) - [A Note on Facades](#a-note-on-facades) - [Package Discovery](#package-discovery) + - [Test State Cleanup](#test-state-cleanup) - [Inspecting Installed Packages](#inspecting-installed-packages) - [Service Providers](#service-providers) - [Provider Priority](#provider-priority) @@ -86,6 +87,48 @@ You may disable package discovery for all packages using the `*` character insid }, ``` + +### Test State Cleanup + +Packages that keep worker-lifetime state for tests may declare a test-state registrar: + +```json +"extra": { + "hypervel": { + "test-state": [ + "Vendor\\Package\\Testing\\TestState" + ] + } +} +``` + +The registrar must be autoloadable from the package's normal `autoload` section and must define `register()`. Use the registrar as one package-level entry point that aggregates the cleanup for any stateful classes your package owns: + +```php + static::flushState()); + } + + public static function flushState(): void + { + InvoiceNumbers::flushState(); + TaxRates::flushState(); + ReceiptMacros::flushState(); + } +} +``` + +Use your Composer package name as the callback name. Registrar classes are discovered during PHPUnit extension bootstrap, so package cleanup runs even in workers that only execute unit tests and never boot a Hypervel application. + ## Inspecting Installed Packages diff --git a/src/boost/docs/testing.md b/src/boost/docs/testing.md index 2626407b4..4b6c4ff2c 100644 --- a/src/boost/docs/testing.md +++ b/src/boost/docs/testing.md @@ -4,6 +4,7 @@ - [Environment](#environment) - [Creating Tests](#creating-tests) - [Running Tests in Coroutines](#running-tests-in-coroutines) + - [Test State Cleanup](#test-state-cleanup) - [Macro State](#macro-state) - [Using Pest](#using-pest) - [Running Tests](#running-tests) @@ -167,41 +168,49 @@ By default, Hypervel copies coroutine context values prepared outside the test m protected bool $copyNonCoroutineContext = false; ``` - -### Macro State + +### Test State Cleanup -Macroable classes store registered macros in static state for the life of the PHP process. Typically, macros should be registered during application boot from a [service provider](/docs/{{version}}/providers). +Hypervel applications keep framework objects, static caches, macros, and manager state in memory for the life of the PHP process. During tests, Hypervel's PHPUnit extension flushes framework-owned state after every test method. -If you register a temporary macro from inside a test, flush that class's macro state before the test finishes so the macro does not affect later tests in the same process: +If your application has its own worker-lifetime state, add its cleanup to `tests/Support/TestState.php`. Use this class as one application-level entry point that aggregates the cleanup for any stateful classes your app owns: ```php static::flushState()); } - public function test_collection_macro(): void + public static function flushState(): void { - Collection::macro('summary', function () { - return $this->implode(', '); - }); - - $this->assertSame('first, second', collect(['first', 'second'])->summary()); + InvoiceNumbers::flushState(); + TaxRates::flushState(); + ReceiptMacros::flushState(); } } ``` +Callbacks registered by your application run after package cleanup callbacks and before Hypervel flushes framework state. This means framework services are still available while your callback runs, then Hypervel tears them down immediately after. + +Do not call `AfterEachTestCleanup::forgetCallbacks()` from ordinary application tests. That method clears all registered callbacks for the current PHPUnit worker, including callbacks discovered from application and package metadata. + + +### Macro State + +Macroable classes store registered macros in static state for the life of the PHP process. Typically, macros should be registered during application boot from a [service provider](/docs/{{version}}/providers). + +Hypervel already flushes framework macroable classes such as `Collection`, `ResponseFactory`, `View\Factory`, and testing helpers after every test. Do not add teardown cleanup for framework classes already handled by Hypervel. + +If your application or package defines its own macroable class and registers temporary macros inside a test, add that class to your test-state cleanup. + ### Using Pest diff --git a/src/foundation/composer.json b/src/foundation/composer.json index 9c3d1c0d5..8f56c0df7 100644 --- a/src/foundation/composer.json +++ b/src/foundation/composer.json @@ -24,6 +24,7 @@ ], "require": { "php": "^8.4", + "composer-runtime-api": "^2.2", "laravel/serializable-closure": "^2.0.10", "nesbot/carbon": "^3.8.4", "psr/clock": "^1.0", diff --git a/src/foundation/src/PackageManifest.php b/src/foundation/src/PackageManifest.php index 3963cb998..6c3653139 100644 --- a/src/foundation/src/PackageManifest.php +++ b/src/foundation/src/PackageManifest.php @@ -183,30 +183,58 @@ protected function getManifest(): array } /** - * Build the manifest and write it to disk. + * Discover installed Hypervel package metadata. + * + * @param array $baseIgnore */ - public function build(): void + public static function discoverInstalledPackages(Filesystem $files, string $vendorPath, array $baseIgnore): array { $packages = []; - if ($this->files->exists($path = $this->vendorPath . '/composer/installed.json')) { - $installed = json_decode($this->files->get($path), true); + if ($files->exists($path = $vendorPath . '/composer/installed.json')) { + $installed = json_decode($files->get($path), true); - $packages = $installed['packages'] ?? $installed; + if (is_array($installed)) { + $installedPackages = $installed['packages'] ?? $installed; + + if (is_array($installedPackages)) { + $packages = $installedPackages; + } + } } - $ignore = $this->packagesToIgnore(); + $ignore = $baseIgnore; + + return (new Collection($packages))->filter(function (mixed $package): bool { + return is_array($package) && is_string($package['name'] ?? null); + })->mapWithKeys(function (array $package) use ($vendorPath) { + $configuration = $package['extra']['hypervel'] ?? []; - $manifest = (new Collection($packages))->mapWithKeys(function (array $package) { - return [$this->format($package['name']) => [ - ...($package['extra']['hypervel'] ?? []), + if (! is_array($configuration)) { + $configuration = []; + } + + return [static::formatPackageName($package['name'], $vendorPath) => [ + ...$configuration, 'version' => $package['version'] ?? null, ]]; })->each(function (array $configuration) use (&$ignore) { - $ignore = array_merge($ignore, $configuration['dont-discover'] ?? []); + $ignore = array_merge($ignore, (array) ($configuration['dont-discover'] ?? [])); })->reject(function (array $configuration, string $package) use ($ignore) { - return in_array($package, $ignore, true); + return in_array('*', $ignore, true) || in_array($package, $ignore, true); })->filter()->all(); + } + + /** + * Build the manifest and write it to disk. + */ + public function build(): void + { + $manifest = static::discoverInstalledPackages( + $this->files, + $this->vendorPath, + $this->packagesToIgnore() + ); $this->write($manifest); @@ -219,7 +247,53 @@ public function build(): void */ protected function format(string $package): string { - return str_replace($this->vendorPath . '/', '', $package); + return static::formatPackageName($package, $this->vendorPath); + } + + /** + * Format the given package name with the given vendor path. + */ + protected static function formatPackageName(string $package, string $vendorPath): string + { + return str_replace($vendorPath . '/', '', $package); + } + + /** + * Get the package names ignored by root composer metadata. + * + * @return array + */ + public static function packagesToIgnoreFromComposer(Filesystem $files, string $basePath): array + { + $ignore = static::rootHypervelExtra($files, $basePath, 'dont-discover'); + + return is_array($ignore) ? $ignore : []; + } + + /** + * Get a root Composer extra.hypervel value. + */ + public static function rootHypervelExtra(Filesystem $files, string $basePath, string $key): mixed + { + if (! $files->isFile($basePath . '/composer.json')) { + return null; + } + + $composer = json_decode($files->get( + $basePath . '/composer.json' + ), true); + + if (! is_array($composer)) { + return null; + } + + $hypervel = $composer['extra']['hypervel'] ?? null; + + if (! is_array($hypervel)) { + return null; + } + + return $hypervel[$key] ?? null; } /** @@ -231,13 +305,7 @@ protected function format(string $package): string */ protected function packagesToIgnore(): array { - if (! is_file($this->basePath . '/composer.json')) { - return []; - } - - return json_decode(file_get_contents( - $this->basePath . '/composer.json' - ), true)['extra']['hypervel']['dont-discover'] ?? []; + return static::packagesToIgnoreFromComposer($this->files, $this->basePath); } /** diff --git a/src/testbench/composer.json b/src/testbench/composer.json index 5b485ae54..46dc2e8be 100644 --- a/src/testbench/composer.json +++ b/src/testbench/composer.json @@ -24,6 +24,7 @@ ], "require": { "php": "^8.4", + "composer-runtime-api": "^2.2", "mockery/mockery": "1.6.x-dev", "phpunit/phpunit": "^13.0.3", "symfony/yaml": "^8.0.12", diff --git a/src/testing/composer.json b/src/testing/composer.json index 668f5bc75..b4c753a44 100644 --- a/src/testing/composer.json +++ b/src/testing/composer.json @@ -31,6 +31,7 @@ "require": { "php": "^8.4", "ext-dom": "*", + "composer-runtime-api": "^2.2", "mockery/mockery": "1.6.x-dev", "symfony/console": "^8.0", "symfony/http-foundation": "^8.0", @@ -43,6 +44,7 @@ "hypervel/contracts": "^0.4", "hypervel/cookie": "^0.4", "hypervel/database": "^0.4", + "hypervel/filesystem": "^0.4", "hypervel/foundation": "^0.4", "hypervel/http": "^0.4", "hypervel/macroable": "^0.4", diff --git a/src/testing/src/PHPUnit/AfterEachTestCleanup.php b/src/testing/src/PHPUnit/AfterEachTestCleanup.php new file mode 100644 index 000000000..62fc8550f --- /dev/null +++ b/src/testing/src/PHPUnit/AfterEachTestCleanup.php @@ -0,0 +1,72 @@ + + */ + protected static array $callbacks = []; + + /** + * Register a callback to flush test state after every test. + * + * Boot or tests only. The callback persists in static state for the + * PHPUnit worker lifetime and runs after every subsequent test. + */ + public static function flushUsing(string $name, callable $callback): void + { + static::$callbacks[$name] = Closure::fromCallable($callback); + } + + /** + * Run registered callbacks. + * + * @throws Throwable + */ + public static function runCallbacks(): void + { + /** @var null|Throwable $exception */ + $exception = null; + + foreach (static::$callbacks as $callback) { + try { + $callback(); + } catch (Throwable $throwable) { + // Keep running cleanup, then surface the first failure as the root cause. + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; + } + } + + /** + * Forget all registered callbacks. + * + * Boot or tests only. This removes process-local cleanup registrations + * for the current PHPUnit worker, including callbacks discovered from + * app and package metadata. + */ + public static function forgetCallbacks(): void + { + static::$callbacks = []; + } +} diff --git a/src/testing/src/PHPUnit/AfterEachTestExtension.php b/src/testing/src/PHPUnit/AfterEachTestExtension.php index a1c064596..db5533c25 100644 --- a/src/testing/src/PHPUnit/AfterEachTestExtension.php +++ b/src/testing/src/PHPUnit/AfterEachTestExtension.php @@ -9,12 +9,6 @@ use PHPUnit\Runner\Extension\ParameterCollection; use PHPUnit\TextUI\Configuration\Configuration; -/** - * PHPUnit extension that registers the AfterEachTestSubscriber. - * - * This ensures Mockery cleanup runs after every test method, eliminating - * the need for explicit m::close() calls in individual test tearDown methods. - */ class AfterEachTestExtension implements Extension { /** @@ -22,6 +16,8 @@ class AfterEachTestExtension implements Extension */ public function bootstrap(Configuration $configuration, Facade $facade, ParameterCollection $parameters): void { + TestStateRegistrars::forRootInstall()->register(); + $facade->registerSubscriber(new AfterEachTestSubscriber); } } diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 75724989a..086767d65 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -21,6 +21,26 @@ class AfterEachTestSubscriber implements FinishedSubscriber * Clean up static state after a test finishes. */ public function notify(Finished $event): void + { + $this->flushStateAfterTest(); + } + + /** + * Flush all test state after a test finishes. + */ + public function flushStateAfterTest(): void + { + try { + AfterEachTestCleanup::runCallbacks(); + } finally { + $this->flushFrameworkState(); + } + } + + /** + * Flush framework-owned static state. + */ + protected function flushFrameworkState(): void { Mockery::close(); diff --git a/src/testing/src/PHPUnit/TestStateRegistrars.php b/src/testing/src/PHPUnit/TestStateRegistrars.php new file mode 100644 index 000000000..5b278b228 --- /dev/null +++ b/src/testing/src/PHPUnit/TestStateRegistrars.php @@ -0,0 +1,141 @@ +files = $files ?? new Filesystem; + } + + /** + * Create a discoverer for the current Composer root install. + */ + public static function forRootInstall(): static + { + $basePath = static::installedRootPath(); + $vendorPath = Env::get('COMPOSER_VENDOR_DIR'); + + return new static( + $basePath, + is_string($vendorPath) && $vendorPath !== '' ? $vendorPath : $basePath . '/vendor' + ); + } + + /** + * Register discovered test-state registrars. + */ + public function register(): void + { + foreach ($this->registrars() as $source => $classes) { + foreach ($classes as $class) { + $this->registerClass($source, $class); + } + } + } + + /** + * Get the Composer root package path. + */ + protected static function installedRootPath(): string + { + $rootPackage = InstalledVersions::getRootPackage(); + + return static::resolveInstalledRootPath($rootPackage['install_path'] ?? null); + } + + /** + * Resolve and validate the Composer root install path. + */ + protected static function resolveInstalledRootPath(mixed $installPath): string + { + if (! is_string($installPath) || $installPath === '') { + throw new RuntimeException('Composer runtime metadata is missing the root package install path.'); + } + + $realPath = realpath($installPath); + + return $realPath === false ? $installPath : $realPath; + } + + /** + * Get root test-state registrar classes. + * + * @return array + */ + protected function rootRegistrars(): array + { + return (array) PackageManifest::rootHypervelExtra($this->files, $this->basePath, 'test-state'); + } + + /** + * Get registrar classes keyed by their source package. + * + * @return array> + */ + protected function registrars(): array + { + $registrars = []; + $baseIgnore = PackageManifest::packagesToIgnoreFromComposer($this->files, $this->basePath); + + foreach (PackageManifest::discoverInstalledPackages($this->files, $this->vendorPath, $baseIgnore) as $package => $configuration) { + if (isset($configuration['test-state'])) { + $registrars[$package] = (array) $configuration['test-state']; + } + } + + $rootRegistrars = $this->rootRegistrars(); + + if ($rootRegistrars !== []) { + $registrars['root'] = $rootRegistrars; + } + + return $registrars; + } + + /** + * Register one registrar class. + */ + protected function registerClass(string $source, mixed $class): void + { + if (! is_string($class) || $class === '') { + throw new RuntimeException( + "Test-state registrar declared by [{$source}] must be a class name string." + ); + } + + if (! class_exists($class)) { + throw new RuntimeException( + "Test-state registrar [{$class}] declared by [{$source}] does not exist. Check that it is in normal autoload, not dependency autoload-dev." + ); + } + + if (! method_exists($class, 'register')) { + throw new RuntimeException( + "Test-state registrar [{$class}] declared by [{$source}] must define a register method." + ); + } + + $class::register(); + } +} diff --git a/tests/Foundation/FoundationPackageManifestTest.php b/tests/Foundation/FoundationPackageManifestTest.php index e46a08474..2f05ec9e4 100644 --- a/tests/Foundation/FoundationPackageManifestTest.php +++ b/tests/Foundation/FoundationPackageManifestTest.php @@ -15,6 +15,13 @@ class FoundationPackageManifestTest extends TestCase private string $manifestPath; + /** + * Temporary directories created by this test. + * + * @var array + */ + private array $tempDirectories = []; + protected function setUp(): void { parent::setUp(); @@ -29,6 +36,12 @@ protected function tearDown(): void { @unlink($this->manifestPath); + $filesystem = new Filesystem; + + foreach ($this->tempDirectories as $directory) { + $filesystem->deleteDirectory($directory); + } + PackageManifest::flushState(); parent::tearDown(); @@ -39,6 +52,19 @@ private function makeManifest(): PackageManifest return new PackageManifest(new Filesystem, $this->basePath, $this->manifestPath); } + private function makeTempComposerRoot(string $name): string + { + $path = sys_get_temp_dir() . '/hypervel_package_manifest_' . getmypid() . '_' . $name; + $filesystem = new Filesystem; + + $filesystem->deleteDirectory($path); + $filesystem->ensureDirectoryExists($path . '/vendor/composer'); + + $this->tempDirectories[] = $path; + + return $path; + } + public function testProvidersReturnsDiscoveredProviders() { $manifest = $this->makeManifest(); @@ -90,6 +116,171 @@ public function testBuildCachesVersions() $this->assertSame('v2.3.0', $cached['vendor-a/package-b']['version']); } + public function testDiscoverInstalledPackagesMatchesBuildCacheForFixtures(): void + { + $filesystem = new Filesystem; + $manifest = $this->makeManifest(); + + $manifest->build(); + + $this->assertSame( + require $this->manifestPath, + PackageManifest::discoverInstalledPackages( + $filesystem, + $this->basePath . '/vendor', + PackageManifest::packagesToIgnoreFromComposer($filesystem, $this->basePath) + ) + ); + } + + public function testDiscoverInstalledPackagesHandlesLegacyBareInstalledJsonFormat(): void + { + $filesystem = new Filesystem; + $basePath = $this->makeTempComposerRoot('legacy'); + $filesystem->put($basePath . '/composer.json', '{}'); + $filesystem->put($basePath . '/vendor/composer/installed.json', json_encode([ + [ + 'name' => 'vendor-a/package-a', + 'version' => 'v1.0.0', + 'extra' => [ + 'hypervel' => [ + 'providers' => ['Vendor\Package\Provider'], + ], + ], + ], + ], JSON_THROW_ON_ERROR)); + + $this->assertSame( + [ + 'vendor-a/package-a' => [ + 'providers' => ['Vendor\Package\Provider'], + 'version' => 'v1.0.0', + ], + ], + PackageManifest::discoverInstalledPackages($filesystem, $basePath . '/vendor', []) + ); + } + + public function testDiscoverInstalledPackagesKeepsVersionOnlyPackagesUnlessIgnored(): void + { + $packages = PackageManifest::discoverInstalledPackages( + new Filesystem, + $this->basePath . '/vendor', + [] + ); + + $this->assertSame(['version' => 'v3.1.4'], $packages['vendor-a/package-d']); + } + + public function testDiscoverInstalledPackagesHonorsWildcardDontDiscover(): void + { + $this->assertSame( + [], + PackageManifest::discoverInstalledPackages(new Filesystem, $this->basePath . '/vendor', ['*']) + ); + } + + public function testDiscoverInstalledPackagesReturnsEmptyArrayForMissingInstalledJson(): void + { + $basePath = $this->makeTempComposerRoot('missing-installed-json'); + + (new Filesystem)->delete($basePath . '/vendor/composer/installed.json'); + + $this->assertSame( + [], + PackageManifest::discoverInstalledPackages(new Filesystem, $basePath . '/vendor', []) + ); + } + + public function testDiscoverInstalledPackagesReturnsEmptyArrayForMalformedInstalledJson(): void + { + $filesystem = new Filesystem; + $basePath = $this->makeTempComposerRoot('malformed-installed-json'); + $filesystem->put($basePath . '/vendor/composer/installed.json', '{'); + + $this->assertSame( + [], + PackageManifest::discoverInstalledPackages($filesystem, $basePath . '/vendor', []) + ); + } + + public function testDiscoverInstalledPackagesReturnsEmptyArrayForMalformedPackagesShape(): void + { + $filesystem = new Filesystem; + $basePath = $this->makeTempComposerRoot('malformed-packages-shape'); + $filesystem->put($basePath . '/vendor/composer/installed.json', json_encode([ + 'packages' => 'invalid', + ], JSON_THROW_ON_ERROR)); + + $this->assertSame( + [], + PackageManifest::discoverInstalledPackages($filesystem, $basePath . '/vendor', []) + ); + } + + public function testDiscoverInstalledPackagesSkipsMalformedPackageEntries(): void + { + $filesystem = new Filesystem; + $basePath = $this->makeTempComposerRoot('malformed-package-entries'); + $filesystem->put($basePath . '/vendor/composer/installed.json', json_encode([ + 'packages' => [ + 'invalid', + [ + 'version' => 'v1.0.0', + ], + [ + 'name' => 'vendor-a/package-a', + 'version' => 'v1.0.0', + ], + [ + 'name' => 'vendor-a/package-b', + 'version' => 'v2.0.0', + 'extra' => [ + 'hypervel' => 'invalid', + ], + ], + ], + ], JSON_THROW_ON_ERROR)); + + $this->assertSame( + [ + 'vendor-a/package-a' => [ + 'version' => 'v1.0.0', + ], + 'vendor-a/package-b' => [ + 'version' => 'v2.0.0', + ], + ], + PackageManifest::discoverInstalledPackages($filesystem, $basePath . '/vendor', []) + ); + } + + public function testPackagesToIgnoreFromComposerReturnsEmptyArrayForMalformedComposerJson(): void + { + $filesystem = new Filesystem; + $basePath = $this->makeTempComposerRoot('malformed-composer-json'); + $filesystem->put($basePath . '/composer.json', '{'); + + $this->assertSame( + [], + PackageManifest::packagesToIgnoreFromComposer($filesystem, $basePath) + ); + } + + public function testRootHypervelExtraReturnsNullForMalformedHypervelMetadata(): void + { + $filesystem = new Filesystem; + $basePath = $this->makeTempComposerRoot('malformed-root-hypervel-metadata'); + $filesystem->put($basePath . '/composer.json', json_encode([ + 'extra' => [ + 'hypervel' => 'invalid', + ], + ], JSON_THROW_ON_ERROR)); + + $this->assertNull(PackageManifest::rootHypervelExtra($filesystem, $basePath, 'test-state')); + $this->assertSame([], PackageManifest::packagesToIgnoreFromComposer($filesystem, $basePath)); + } + public function testVersionReturnsPackageVersion() { $manifest = $this->makeManifest(); diff --git a/tests/Testbench/Foundation/Fixtures/PackageManifest/composer.json b/tests/Testbench/Foundation/Fixtures/PackageManifest/composer.json index c28c938d3..d256f5c01 100644 --- a/tests/Testbench/Foundation/Fixtures/PackageManifest/composer.json +++ b/tests/Testbench/Foundation/Fixtures/PackageManifest/composer.json @@ -7,7 +7,10 @@ ], "aliases": { "RootAlias": "RootClass" - } + }, + "dont-discover": [ + "vendor-a/package-a" + ] } } } diff --git a/tests/Testbench/Foundation/PackageManifestTest.php b/tests/Testbench/Foundation/PackageManifestTest.php index d5fe896d2..b69b52b86 100644 --- a/tests/Testbench/Foundation/PackageManifestTest.php +++ b/tests/Testbench/Foundation/PackageManifestTest.php @@ -127,6 +127,21 @@ public function itCanBuildManifestFromFixtures(): void $this->assertArrayHasKey('vendor-a/package-d', $cached); } + #[Test] + public function itDoesNotApplyRootComposerDontDiscoverDuringBuild(): void + { + $manifest = $this->makeManifest( + testbench: $this->makeTestbench([]), + rootPackage: $this->rootPackageFixture() + ); + + $manifest->build(); + + $cached = require $this->manifestPath; + + $this->assertArrayHasKey('vendor-a/package-a', $cached); + } + #[Test] public function itCanBuildManifestWithoutRootComposerMetadata(): void { diff --git a/tests/Testing/PHPUnit/AfterEachTestCleanupTest.php b/tests/Testing/PHPUnit/AfterEachTestCleanupTest.php new file mode 100644 index 000000000..691da414b --- /dev/null +++ b/tests/Testing/PHPUnit/AfterEachTestCleanupTest.php @@ -0,0 +1,135 @@ +assertSame(['package'], $calls); + } + + public function testCallbacksRunInRegistrationOrder(): void + { + $calls = []; + + AfterEachTestCleanup::flushUsing('vendor/first', function () use (&$calls): void { + $calls[] = 'first'; + }); + AfterEachTestCleanup::flushUsing('vendor/second', function () use (&$calls): void { + $calls[] = 'second'; + }); + + AfterEachTestCleanup::runCallbacks(); + + $this->assertSame(['first', 'second'], $calls); + } + + public function testCallbacksPersistAcrossRuns(): void + { + $calls = []; + + AfterEachTestCleanup::flushUsing('vendor/package', function () use (&$calls): void { + $calls[] = 'package'; + }); + + AfterEachTestCleanup::runCallbacks(); + AfterEachTestCleanup::runCallbacks(); + + $this->assertSame(['package', 'package'], $calls); + } + + public function testRegisteringTheSameNameReplacesTheCallback(): void + { + $calls = []; + + AfterEachTestCleanup::flushUsing('vendor/package', function () use (&$calls): void { + $calls[] = 'first'; + }); + AfterEachTestCleanup::flushUsing('vendor/package', function () use (&$calls): void { + $calls[] = 'second'; + }); + + AfterEachTestCleanup::runCallbacks(); + + $this->assertSame(['second'], $calls); + } + + public function testRootCallbackRegisteredAfterPackageCallbackRunsAfterPackageCallback(): void + { + $calls = []; + + AfterEachTestCleanup::flushUsing('vendor/package', function () use (&$calls): void { + $calls[] = 'package'; + }); + AfterEachTestCleanup::flushUsing('app', function () use (&$calls): void { + $calls[] = 'app'; + }); + + AfterEachTestCleanup::runCallbacks(); + + $this->assertSame(['package', 'app'], $calls); + } + + public function testForgetCallbacksClearsCallbacks(): void + { + $calls = []; + + AfterEachTestCleanup::flushUsing('vendor/package', function () use (&$calls): void { + $calls[] = 'package'; + }); + + AfterEachTestCleanup::forgetCallbacks(); + AfterEachTestCleanup::runCallbacks(); + + $this->assertSame([], $calls); + } + + public function testRunCallbacksContinuesAfterExceptionAndRethrowsFirstException(): void + { + $calls = []; + $firstException = new RuntimeException('first'); + + AfterEachTestCleanup::flushUsing('vendor/first', function () use (&$calls, $firstException): void { + $calls[] = 'first'; + + throw $firstException; + }); + AfterEachTestCleanup::flushUsing('vendor/second', function () use (&$calls): void { + $calls[] = 'second'; + }); + + try { + AfterEachTestCleanup::runCallbacks(); + + $this->fail('Expected callback exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($firstException, $exception); + } + + $this->assertSame(['first', 'second'], $calls); + } +} diff --git a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php new file mode 100644 index 000000000..7e47ec790 --- /dev/null +++ b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php @@ -0,0 +1,87 @@ + + */ + public array $order = []; + + /** + * Flush framework-owned static state. + */ + protected function flushFrameworkState(): void + { + $this->order[] = 'framework'; + } + }; + + AfterEachTestCleanup::flushUsing('vendor/package', function () use ($subscriber): void { + $subscriber->order[] = 'custom'; + }); + + $subscriber->flushStateAfterTest(); + + $this->assertSame(['custom', 'framework'], $subscriber->order); + } + + public function testFlushStateAfterTestRunsFrameworkCleanupWhenCustomCallbackThrows(): void + { + $subscriber = new class extends AfterEachTestSubscriber { + /** + * The observed cleanup order. + * + * @var array + */ + public array $order = []; + + /** + * Flush framework-owned static state. + */ + protected function flushFrameworkState(): void + { + $this->order[] = 'framework'; + } + }; + $expectedException = new RuntimeException('custom cleanup failed'); + + AfterEachTestCleanup::flushUsing('vendor/package', function () use ($subscriber, $expectedException): void { + $subscriber->order[] = 'custom'; + + throw $expectedException; + }); + + try { + $subscriber->flushStateAfterTest(); + + $this->fail('Expected cleanup exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($expectedException, $exception); + } + + $this->assertSame(['custom', 'framework'], $subscriber->order); + } +} diff --git a/tests/Testing/PHPUnit/TestStateRegistrarsTest.php b/tests/Testing/PHPUnit/TestStateRegistrarsTest.php new file mode 100644 index 000000000..2f892d66a --- /dev/null +++ b/tests/Testing/PHPUnit/TestStateRegistrarsTest.php @@ -0,0 +1,373 @@ +filesystem = new Filesystem; + $this->basePath = ParallelTesting::tempDir('TestStateRegistrarsTest'); + + $this->filesystem->deleteDirectory($this->basePath); + $this->filesystem->ensureDirectoryExists($this->basePath . '/vendor/composer'); + + TestStateRegistrarRecorder::$calls = []; + } + + #[Override] + protected function tearDown(): void + { + AfterEachTestCleanup::forgetCallbacks(); + TestStateRegistrarRecorder::$calls = []; + $this->filesystem->deleteDirectory($this->basePath); + + parent::tearDown(); + } + + public function testRegistersPackageRegistrarsFromInstalledPackages(): void + { + $this->writeRootComposer(); + $this->writeInstalledPackages([ + $this->package('vendor/package', [PackageTestStateRegistrar::class]), + ]); + + $this->makeRegistrars()->register(); + AfterEachTestCleanup::runCallbacks(); + + $this->assertSame(['package'], TestStateRegistrarRecorder::$calls); + } + + public function testRegistersRootRegistrarsAfterPackageRegistrars(): void + { + $this->writeRootComposer([ + 'test-state' => [RootTestStateRegistrar::class], + ]); + $this->writeInstalledPackages([ + $this->package('vendor/package', [PackageTestStateRegistrar::class]), + ]); + + $this->makeRegistrars()->register(); + AfterEachTestCleanup::runCallbacks(); + + $this->assertSame(['package', 'root'], TestStateRegistrarRecorder::$calls); + } + + public function testPackageDontDiscoverSuppressesPackageTestStateDiscovery(): void + { + $this->writeRootComposer(); + $this->writeInstalledPackages([ + $this->package('vendor/first', [], ['vendor/second']), + $this->package('vendor/second', [PackageTestStateRegistrar::class]), + ]); + + $this->makeRegistrars()->register(); + AfterEachTestCleanup::runCallbacks(); + + $this->assertSame([], TestStateRegistrarRecorder::$calls); + } + + public function testRootDontDiscoverSuppressesPackageRegistrarsButNotRootRegistrars(): void + { + $this->writeRootComposer([ + 'dont-discover' => ['*'], + 'test-state' => [RootTestStateRegistrar::class], + ]); + $this->writeInstalledPackages([ + $this->package('vendor/package', [PackageTestStateRegistrar::class]), + ]); + + $this->makeRegistrars()->register(); + AfterEachTestCleanup::runCallbacks(); + + $this->assertSame(['root'], TestStateRegistrarRecorder::$calls); + } + + public function testMissingRootComposerJsonDoesNotThrow(): void + { + $this->writeInstalledPackages([]); + + $this->makeRegistrars()->register(); + AfterEachTestCleanup::runCallbacks(); + + $this->assertSame([], TestStateRegistrarRecorder::$calls); + } + + public function testMissingInstalledJsonDoesNotThrow(): void + { + $this->writeRootComposer(); + + $this->makeRegistrars()->register(); + AfterEachTestCleanup::runCallbacks(); + + $this->assertSame([], TestStateRegistrarRecorder::$calls); + } + + public function testMalformedComposerAndInstalledJsonDoNotThrow(): void + { + $this->filesystem->put($this->basePath . '/composer.json', '{'); + $this->filesystem->put($this->basePath . '/vendor/composer/installed.json', '{'); + + $this->makeRegistrars()->register(); + AfterEachTestCleanup::runCallbacks(); + + $this->assertSame([], TestStateRegistrarRecorder::$calls); + } + + public function testMissingDeclaredRegistrarThrows(): void + { + $this->writeRootComposer(); + $this->writeInstalledPackages([ + $this->package('vendor/package', ['Missing\TestStateRegistrar']), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Test-state registrar [Missing\TestStateRegistrar] declared by [vendor/package] does not exist.'); + + $this->makeRegistrars()->register(); + } + + public function testDeclaredRegistrarWithoutRegisterMethodThrows(): void + { + $this->writeRootComposer(); + $this->writeInstalledPackages([ + $this->package('vendor/package', [RegistrarWithoutRegisterMethod::class]), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Test-state registrar [' . RegistrarWithoutRegisterMethod::class . '] declared by [vendor/package] must define a register method.'); + + $this->makeRegistrars()->register(); + } + + public function testNonStringDeclaredRegistrarThrows(): void + { + $this->writeRootComposer(); + $this->writeInstalledPackages([ + $this->package('vendor/package', [123]), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Test-state registrar declared by [vendor/package] must be a class name string.'); + + $this->makeRegistrars()->register(); + } + + public function testDuplicateCallbackNamesAreLastWins(): void + { + $this->writeRootComposer(); + $this->writeInstalledPackages([ + $this->package('vendor/first', [PackageTestStateRegistrar::class]), + $this->package('vendor/second', [ReplacementPackageTestStateRegistrar::class]), + ]); + + $this->makeRegistrars()->register(); + AfterEachTestCleanup::runCallbacks(); + + $this->assertSame(['replacement'], TestStateRegistrarRecorder::$calls); + } + + public function testForRootInstallUsesComposerVendorDirEnvironmentOverride(): void + { + $originalVendorPath = Env::get('COMPOSER_VENDOR_DIR'); + $customVendorPath = $this->basePath . '/custom-vendor'; + + Env::deleteMany(['COMPOSER_VENDOR_DIR']); + Env::flushRepository(); + Env::getRepository()->set('COMPOSER_VENDOR_DIR', $customVendorPath); + + try { + $this->assertSame( + $customVendorPath, + TestStateRegistrarsProbe::forRootInstall()->vendorPath() + ); + } finally { + Env::deleteMany(['COMPOSER_VENDOR_DIR']); + Env::flushRepository(); + + if (is_string($originalVendorPath)) { + Env::getRepository()->set('COMPOSER_VENDOR_DIR', $originalVendorPath); + } + } + } + + #[DataProvider('invalidComposerRootInstallPaths')] + public function testResolveInstalledRootPathThrowsForInvalidComposerRootInstallPath(mixed $installPath): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Composer runtime metadata is missing the root package install path.'); + + TestStateRegistrarsProbe::resolveInstalledRootPath($installPath); + } + + public static function invalidComposerRootInstallPaths(): array + { + return [ + 'null' => [null], + 'empty string' => [''], + 'non-string' => [123], + ]; + } + + public function testResolveInstalledRootPathReturnsRealPathWhenAvailable(): void + { + $this->assertSame( + realpath($this->basePath), + TestStateRegistrarsProbe::resolveInstalledRootPath($this->basePath) + ); + } + + private function makeRegistrars(): TestStateRegistrars + { + return new TestStateRegistrars( + $this->basePath, + $this->basePath . '/vendor', + $this->filesystem + ); + } + + /** + * Write the root composer.json file. + * + * @param array $hypervel + */ + private function writeRootComposer(array $hypervel = []): void + { + $this->filesystem->put($this->basePath . '/composer.json', json_encode([ + 'extra' => [ + 'hypervel' => $hypervel, + ], + ], JSON_THROW_ON_ERROR)); + } + + /** + * Write Composer's installed package metadata. + * + * @param array> $packages + */ + private function writeInstalledPackages(array $packages): void + { + $this->filesystem->put($this->basePath . '/vendor/composer/installed.json', json_encode([ + 'packages' => $packages, + ], JSON_THROW_ON_ERROR)); + } + + /** + * Build package metadata. + * + * @param array $registrars + * @param array $dontDiscover + * + * @return array + */ + private function package(string $name, array $registrars = [], array $dontDiscover = []): array + { + $hypervel = []; + + if ($registrars !== []) { + $hypervel['test-state'] = $registrars; + } + + if ($dontDiscover !== []) { + $hypervel['dont-discover'] = $dontDiscover; + } + + return [ + 'name' => $name, + 'version' => 'v1.0.0', + 'extra' => [ + 'hypervel' => $hypervel, + ], + ]; + } +} + +class TestStateRegistrarRecorder +{ + /** + * The recorded cleanup calls. + * + * @var array + */ + public static array $calls = []; +} + +class PackageTestStateRegistrar +{ + /** + * Register package test-state cleanup. + */ + public static function register(): void + { + AfterEachTestCleanup::flushUsing('vendor/package', function (): void { + TestStateRegistrarRecorder::$calls[] = 'package'; + }); + } +} + +class ReplacementPackageTestStateRegistrar +{ + /** + * Register replacement package test-state cleanup. + */ + public static function register(): void + { + AfterEachTestCleanup::flushUsing('vendor/package', function (): void { + TestStateRegistrarRecorder::$calls[] = 'replacement'; + }); + } +} + +class RootTestStateRegistrar +{ + /** + * Register root test-state cleanup. + */ + public static function register(): void + { + AfterEachTestCleanup::flushUsing('app', function (): void { + TestStateRegistrarRecorder::$calls[] = 'root'; + }); + } +} + +class RegistrarWithoutRegisterMethod +{ +} + +class TestStateRegistrarsProbe extends TestStateRegistrars +{ + /** + * Resolve and validate the Composer root install path. + */ + public static function resolveInstalledRootPath(mixed $installPath): string + { + return parent::resolveInstalledRootPath($installPath); + } + + /** + * Get the configured vendor path. + */ + public function vendorPath(): string + { + return $this->vendorPath; + } +}