From 2f39a8d69c2875c0dfdb29d4d6567e8ca3dee2f4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:31:30 +0000 Subject: [PATCH 01/15] docs: add test cleanup registrar implementation plan Document the agreed design for extending after-each test cleanup to apps and packages. The plan records why cleanup registration happens from the PHPUnit extension rather than service providers, how package metadata is discovered without writing the package manifest, and how app skeletons should expose a generated Tests\Support\TestState entry point. It also captures the testing strategy, self-review checklist, and the final decisions from the Codesonic design review so future work can understand the intended shape of the API. --- ...4-testing-after-each-cleanup-registrars.md | 1133 +++++++++++++++++ 1 file changed, 1133 insertions(+) create mode 100644 docs/plans/2026-07-04-testing-after-each-cleanup-registrars.md 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. From b8656b3d79811cfdb50ad57710798c2c67514a18 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:31:38 +0000 Subject: [PATCH 02/15] refactor(foundation): extract installed package metadata discovery Move Composer installed package scanning out of PackageManifest::build() into a reusable no-write helper. The new discoverInstalledPackages() method preserves the existing build semantics while making package metadata available to PHPUnit extension bootstrap, where writing bootstrap/cache is not appropriate. The extraction keeps build() using the virtual packagesToIgnore() seam so testbench can continue to override root composer ignores. The existing format() method remains available and delegates to a static formatter for callers that still depend on the instance method. The helper also centralizes metadata parsing robustness for missing or malformed installed.json files, non-array dont-discover metadata, and wildcard package discovery disables. --- src/foundation/src/PackageManifest.php | 78 ++++++++++++++++++++------ 1 file changed, 60 insertions(+), 18 deletions(-) diff --git a/src/foundation/src/PackageManifest.php b/src/foundation/src/PackageManifest.php index 3963cb998..31208e792 100644 --- a/src/foundation/src/PackageManifest.php +++ b/src/foundation/src/PackageManifest.php @@ -183,30 +183,46 @@ 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)) { + $packages = $installed['packages'] ?? $installed; + } } - $ignore = $this->packagesToIgnore(); + $ignore = $baseIgnore; - $manifest = (new Collection($packages))->mapWithKeys(function (array $package) { - return [$this->format($package['name']) => [ + 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'] ?? []); + $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 +235,39 @@ 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 + { + if (! $files->isFile($basePath . '/composer.json')) { + return []; + } + + $composer = json_decode($files->get( + $basePath . '/composer.json' + ), true); + + if (! is_array($composer)) { + return []; + } + + $ignore = $composer['extra']['hypervel']['dont-discover'] ?? []; + + return is_array($ignore) ? $ignore : []; } /** @@ -231,13 +279,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); } /** From 40167b97413580414289c429d7cb3cee35446ca2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:31:46 +0000 Subject: [PATCH 03/15] test(foundation): cover no-write package discovery Add regression coverage for PackageManifest::discoverInstalledPackages() so the extracted helper stays byte-compatible with the manifest that build() writes for the existing fixtures. The tests cover Composer 2 and legacy installed.json shapes, version-only packages, wildcard dont-discover handling, missing installed metadata, malformed installed metadata, and malformed root composer metadata. This locks in the parsing behavior needed by PHPUnit bootstrap discovery without requiring the helper to write package cache files. --- .../FoundationPackageManifestTest.php | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/tests/Foundation/FoundationPackageManifestTest.php b/tests/Foundation/FoundationPackageManifestTest.php index e46a08474..e5cda98cf 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,106 @@ 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 testPackagesToIgnoreFromComposerReturnsEmptyArrayForMalformedComposerJson(): void + { + $filesystem = new Filesystem; + $basePath = $this->makeTempComposerRoot('malformed-composer-json'); + $filesystem->put($basePath . '/composer.json', '{'); + + $this->assertSame( + [], + PackageManifest::packagesToIgnoreFromComposer($filesystem, $basePath) + ); + } + public function testVersionReturnsPackageVersion() { $manifest = $this->makeManifest(); From 9bd744fe55faafb8d3d90af9b3c2e62291177402 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:31:55 +0000 Subject: [PATCH 04/15] test(testbench): preserve package manifest ignore overrides Add a fixture-level root dont-discover entry and assert testbench package manifest builds still ignore that project-level metadata during build. This protects the virtual packagesToIgnore() seam used by testbench: the foundation manifest can read root composer ignores, while testbench remains free to manage discovery filtering at read time through its own ignore list and required-package behavior. --- .../Fixtures/PackageManifest/composer.json | 5 ++++- .../Testbench/Foundation/PackageManifestTest.php | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) 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 { From 2ad2fb5ca6d775704b22057b61976e56ddbd83da Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:32:03 +0000 Subject: [PATCH 05/15] feat(testing): add after-each cleanup callback registry Introduce AfterEachTestCleanup as the process-local registry for app and package cleanup callbacks that should run after every PHPUnit test. Callbacks are keyed by name so registrations are idempotent and duplicate names are last-wins. runCallbacks() continues through all registered callbacks before rethrowing the first exception, preventing one broken cleanup from suppressing the rest. The registry intentionally has no flushState() method because its callbacks are suite-level registrations that must survive for the PHPUnit worker lifetime. Tests cover registration order, persistence, replacement, clearing, root-after-package ordering, and exception isolation. --- .../src/PHPUnit/AfterEachTestCleanup.php | 71 +++++++++ .../PHPUnit/AfterEachTestCleanupTest.php | 135 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 src/testing/src/PHPUnit/AfterEachTestCleanup.php create mode 100644 tests/Testing/PHPUnit/AfterEachTestCleanupTest.php diff --git a/src/testing/src/PHPUnit/AfterEachTestCleanup.php b/src/testing/src/PHPUnit/AfterEachTestCleanup.php new file mode 100644 index 000000000..6c19e50f4 --- /dev/null +++ b/src/testing/src/PHPUnit/AfterEachTestCleanup.php @@ -0,0 +1,71 @@ + + */ + 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) { + $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/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); + } +} From ac390e7d87bd58df20423f855aa41da4c3532dbf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:32:16 +0000 Subject: [PATCH 06/15] feat(testing): run registered cleanup before framework cleanup Refactor AfterEachTestSubscriber so notify() delegates to flushStateAfterTest(), which runs app and package callbacks before the framework-owned cleanup list. The framework cleanup body now lives in flushFrameworkState(), and it still runs from a finally block so framework state is flushed even when a custom cleanup callback throws. The existing first-party optional package cleanup methods remain grouped at the bottom of the framework cleanup list. --- .../src/PHPUnit/AfterEachTestSubscriber.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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(); From 134552501383ee89466dd738036ed190f2836b1c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:32:22 +0000 Subject: [PATCH 07/15] test(testing): cover after-test cleanup ordering Add focused tests for AfterEachTestSubscriber::flushStateAfterTest() using an anonymous subclass that observes framework cleanup without tearing down the live test process. The tests prove custom callbacks run before framework cleanup and that framework cleanup still runs when a custom callback throws, while preserving the original callback exception. --- .../PHPUnit/AfterEachTestSubscriberTest.php | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php 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); + } +} From a363a2155b070bc3baa945c69f09cad14b82cea2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:32:35 +0000 Subject: [PATCH 08/15] feat(testing): discover test-state registrars from composer metadata Discover app and package test-state registrars during PHPUnit extension bootstrap, before the after-each subscriber is registered. Package registrars are read from installed package metadata through the no-write PackageManifest helper, while root registrars are read from root composer.json and appended last. This makes cleanup available even in workers that only execute app-less unit tests and never boot a Hypervel application. The testing package now declares its direct filesystem dependency because registrar discovery reads Composer metadata directly. --- src/testing/composer.json | 1 + .../src/PHPUnit/AfterEachTestExtension.php | 8 +- .../src/PHPUnit/TestStateRegistrars.php | 147 ++++++++++++++++++ 3 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 src/testing/src/PHPUnit/TestStateRegistrars.php diff --git a/src/testing/composer.json b/src/testing/composer.json index 668f5bc75..db54adbc6 100644 --- a/src/testing/composer.json +++ b/src/testing/composer.json @@ -43,6 +43,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/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/TestStateRegistrars.php b/src/testing/src/PHPUnit/TestStateRegistrars.php new file mode 100644 index 000000000..6185e500e --- /dev/null +++ b/src/testing/src/PHPUnit/TestStateRegistrars.php @@ -0,0 +1,147 @@ +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(); + $basePath = $rootPackage['install_path'] ?? getcwd(); + + if (! is_string($basePath) || $basePath === '') { + $currentPath = getcwd(); + $basePath = $currentPath === false ? '.' : $currentPath; + } + + $realPath = realpath($basePath); + + return $realPath === false ? $basePath : $realPath; + } + + /** + * 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, 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(); + } +} From 13c9235610846334f8d672710da00dc6c50b0a75 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:32:42 +0000 Subject: [PATCH 09/15] test(testing): cover test-state registrar discovery Add coverage for package and root test-state registrar discovery, package-before-root callback ordering, root and package dont-discover behavior, duplicate callback names, and COMPOSER_VENDOR_DIR handling. The test matrix also locks in the fail-fast boundary for declared bad metadata: non-string registrar values, missing classes, and classes without register() throw attributed RuntimeExceptions, while missing or malformed composer metadata degrades to no custom cleanup. --- .../PHPUnit/TestStateRegistrarsTest.php | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 tests/Testing/PHPUnit/TestStateRegistrarsTest.php diff --git a/tests/Testing/PHPUnit/TestStateRegistrarsTest.php b/tests/Testing/PHPUnit/TestStateRegistrarsTest.php new file mode 100644 index 000000000..1ac3e6850 --- /dev/null +++ b/tests/Testing/PHPUnit/TestStateRegistrarsTest.php @@ -0,0 +1,338 @@ +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); + } + } + } + + 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 +{ + /** + * Get the configured vendor path. + */ + public function vendorPath(): string + { + return $this->vendorPath; + } +} From 6b2d65498dbce33f71ef9630d5e3ad6b6317b25b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:32:51 +0000 Subject: [PATCH 10/15] docs(testing): document application test-state cleanup Add the Test State Cleanup section to the testing guide and explain how applications should use tests/Support/TestState as one app-level cleanup entry point. The macro guidance now reflects that Hypervel already flushes framework macroable classes after each test, so apps should only register cleanup for their own worker-lifetime state. The docs also warn that forgetCallbacks() clears discovered app and package registrations for the current PHPUnit worker. --- src/boost/docs/testing.md | 45 +++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 18 deletions(-) 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 From c963afbfb63ee1507fb07879b071f824188f9af6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:32:57 +0000 Subject: [PATCH 11/15] docs(packages): document test-state registrar metadata Document the extra.hypervel.test-state composer metadata for package authors. The new package docs explain that registrar classes must be normal package autoload classes, should aggregate cleanup for the package's stateful classes, and are discovered during PHPUnit extension bootstrap so cleanup runs even in workers that never boot an application. --- src/boost/docs/packages.md | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) 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 From cc0ab1f8eea55f06736bfee0a9031971cf4a2e24 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:33:03 +0000 Subject: [PATCH 12/15] docs: update static cleanup contributor guidance Clarify where different kinds of test cleanup belong after introducing app and package test-state registrars. Framework-owned classes still belong in AfterEachTestSubscriber, first-party optional package cleanup remains grouped at the bottom of that subscriber, and third-party packages, private packages, and applications should use extra.hypervel.test-state registrars instead. The guidance also corrects the stale subscriber path and explicitly warns not to add AfterEachTestCleanup itself to the subscriber, because its callbacks must persist for the PHPUnit worker lifetime. --- AGENTS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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. From d2cefa95621ee2f5f1d83fa0959d9da1c8a88111 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:32:45 +0000 Subject: [PATCH 13/15] fix(testing): harden cleanup registrar discovery Handle malformed Composer installed package metadata without crashing package discovery during PHPUnit extension bootstrap. Invalid package lists now degrade to an empty manifest, malformed package entries are skipped before mapping, and non-array extra.hypervel metadata is treated as empty package metadata while preserving package versions. Centralize root extra.hypervel reads through PackageManifest::rootHypervelExtra so root dont-discover and test-state metadata share the same composer.json parsing behavior. Make TestStateRegistrars fail fast when Composer runtime metadata does not provide a valid root install path instead of guessing with getcwd. The root path validation is separated from the Composer lookup and covered through the test probe, including invalid values and realpath normalization. Declare composer-runtime-api ^2.2 for split packages that use Composer\InstalledVersions directly: foundation, testbench, and testing. Permission remains unchanged because its usage is optional and guarded. Document the first-exception-wins cleanup callback behavior and add focused tests for the malformed metadata and root path validation cases. --- src/foundation/composer.json | 1 + src/foundation/src/PackageManifest.php | 36 ++++++++++--- src/testbench/composer.json | 1 + src/testing/composer.json | 1 + .../src/PHPUnit/AfterEachTestCleanup.php | 1 + .../src/PHPUnit/TestStateRegistrars.php | 32 +++++------- .../FoundationPackageManifestTest.php | 51 +++++++++++++++++++ .../PHPUnit/TestStateRegistrarsTest.php | 32 ++++++++++++ 8 files changed, 128 insertions(+), 27 deletions(-) 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 31208e792..0ceba755b 100644 --- a/src/foundation/src/PackageManifest.php +++ b/src/foundation/src/PackageManifest.php @@ -195,15 +195,27 @@ public static function discoverInstalledPackages(Filesystem $files, string $vend $installed = json_decode($files->get($path), true); if (is_array($installed)) { - $packages = $installed['packages'] ?? $installed; + $installedPackages = $installed['packages'] ?? $installed; + + if (is_array($installedPackages)) { + $packages = $installedPackages; + } } } $ignore = $baseIgnore; - return (new Collection($packages))->mapWithKeys(function (array $package) use ($vendorPath) { + 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'] ?? []; + + if (! is_array($configuration)) { + $configuration = []; + } + return [static::formatPackageName($package['name'], $vendorPath) => [ - ...($package['extra']['hypervel'] ?? []), + ...$configuration, 'version' => $package['version'] ?? null, ]]; })->each(function (array $configuration) use (&$ignore) { @@ -252,9 +264,19 @@ protected static function formatPackageName(string $package, string $vendorPath) * @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 []; + return null; } $composer = json_decode($files->get( @@ -262,12 +284,10 @@ public static function packagesToIgnoreFromComposer(Filesystem $files, string $b ), true); if (! is_array($composer)) { - return []; + return null; } - $ignore = $composer['extra']['hypervel']['dont-discover'] ?? []; - - return is_array($ignore) ? $ignore : []; + return $composer['extra']['hypervel'][$key] ?? null; } /** 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 db54adbc6..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", diff --git a/src/testing/src/PHPUnit/AfterEachTestCleanup.php b/src/testing/src/PHPUnit/AfterEachTestCleanup.php index 6c19e50f4..62fc8550f 100644 --- a/src/testing/src/PHPUnit/AfterEachTestCleanup.php +++ b/src/testing/src/PHPUnit/AfterEachTestCleanup.php @@ -48,6 +48,7 @@ public static function runCallbacks(): void try { $callback(); } catch (Throwable $throwable) { + // Keep running cleanup, then surface the first failure as the root cause. $exception ??= $throwable; } } diff --git a/src/testing/src/PHPUnit/TestStateRegistrars.php b/src/testing/src/PHPUnit/TestStateRegistrars.php index 6185e500e..5b278b228 100644 --- a/src/testing/src/PHPUnit/TestStateRegistrars.php +++ b/src/testing/src/PHPUnit/TestStateRegistrars.php @@ -60,16 +60,22 @@ public function register(): void protected static function installedRootPath(): string { $rootPackage = InstalledVersions::getRootPackage(); - $basePath = $rootPackage['install_path'] ?? getcwd(); - if (! is_string($basePath) || $basePath === '') { - $currentPath = getcwd(); - $basePath = $currentPath === false ? '.' : $currentPath; + 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($basePath); + $realPath = realpath($installPath); - return $realPath === false ? $basePath : $realPath; + return $realPath === false ? $installPath : $realPath; } /** @@ -79,19 +85,7 @@ protected static function installedRootPath(): string */ 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'] ?? []); + return (array) PackageManifest::rootHypervelExtra($this->files, $this->basePath, 'test-state'); } /** diff --git a/tests/Foundation/FoundationPackageManifestTest.php b/tests/Foundation/FoundationPackageManifestTest.php index e5cda98cf..17629b420 100644 --- a/tests/Foundation/FoundationPackageManifestTest.php +++ b/tests/Foundation/FoundationPackageManifestTest.php @@ -204,6 +204,57 @@ public function testDiscoverInstalledPackagesReturnsEmptyArrayForMalformedInstal ); } + 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; diff --git a/tests/Testing/PHPUnit/TestStateRegistrarsTest.php b/tests/Testing/PHPUnit/TestStateRegistrarsTest.php index 1ac3e6850..27a751e9e 100644 --- a/tests/Testing/PHPUnit/TestStateRegistrarsTest.php +++ b/tests/Testing/PHPUnit/TestStateRegistrarsTest.php @@ -208,6 +208,30 @@ public function testForRootInstallUsesComposerVendorDirEnvironmentOverride(): vo } } + public function testResolveInstalledRootPathThrowsForInvalidComposerRootInstallPath(): void + { + foreach ([null, '', 123] as $installPath) { + try { + TestStateRegistrarsProbe::resolveInstalledRootPath($installPath); + + $this->fail('Expected a runtime exception for invalid Composer root install path.'); + } catch (RuntimeException $exception) { + $this->assertSame( + 'Composer runtime metadata is missing the root package install path.', + $exception->getMessage() + ); + } + } + } + + public function testResolveInstalledRootPathReturnsRealPathWhenAvailable(): void + { + $this->assertSame( + realpath($this->basePath), + TestStateRegistrarsProbe::resolveInstalledRootPath($this->basePath) + ); + } + private function makeRegistrars(): TestStateRegistrars { return new TestStateRegistrars( @@ -328,6 +352,14 @@ 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. */ From 7ac0a83a1441bfd668cd678b055123026b7a6dbf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 5 Jul 2026 03:01:47 +0000 Subject: [PATCH 14/15] test(testing): isolate registrar path validation cases Replace the manual invalid-install-path loop in TestStateRegistrarsTest with a PHPUnit DataProvider so each malformed Composer root install path is reported as its own dataset. This keeps the same guard coverage for null, empty string, and non-string install_path values while matching the repository preference for PHPUnit attributes over inline parameter loops. Validation run before commit: - ./vendor/bin/phpunit --no-progress tests/Testing/PHPUnit/TestStateRegistrarsTest.php - composer fix --- .../PHPUnit/TestStateRegistrarsTest.php | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/tests/Testing/PHPUnit/TestStateRegistrarsTest.php b/tests/Testing/PHPUnit/TestStateRegistrarsTest.php index 27a751e9e..2f892d66a 100644 --- a/tests/Testing/PHPUnit/TestStateRegistrarsTest.php +++ b/tests/Testing/PHPUnit/TestStateRegistrarsTest.php @@ -11,6 +11,7 @@ use Hypervel\Testing\PHPUnit\TestStateRegistrars; use Hypervel\Tests\TestCase; use Override; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; class TestStateRegistrarsTest extends TestCase @@ -208,20 +209,22 @@ public function testForRootInstallUsesComposerVendorDirEnvironmentOverride(): vo } } - public function testResolveInstalledRootPathThrowsForInvalidComposerRootInstallPath(): void + #[DataProvider('invalidComposerRootInstallPaths')] + public function testResolveInstalledRootPathThrowsForInvalidComposerRootInstallPath(mixed $installPath): void { - foreach ([null, '', 123] as $installPath) { - try { - TestStateRegistrarsProbe::resolveInstalledRootPath($installPath); - - $this->fail('Expected a runtime exception for invalid Composer root install path.'); - } catch (RuntimeException $exception) { - $this->assertSame( - 'Composer runtime metadata is missing the root package install path.', - $exception->getMessage() - ); - } - } + $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 From c67b90b36bf09fbf8b41aff15596ce3a38431003 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 5 Jul 2026 03:11:11 +0000 Subject: [PATCH 15/15] fix(foundation): guard root hypervel metadata shape Normalize root composer extra.hypervel reads before returning keyed metadata so malformed root metadata is ignored consistently with installed package metadata. This keeps root test-state and dont-discover discovery on the same defensive parsing path and prevents malformed root composer metadata from being treated as meaningful configuration. Adds focused coverage for malformed root extra.hypervel metadata through both rootHypervelExtra and packagesToIgnoreFromComposer. Validation run before commit: - ./vendor/bin/phpunit --no-progress tests/Foundation/FoundationPackageManifestTest.php - composer fix --- src/foundation/src/PackageManifest.php | 8 +++++++- tests/Foundation/FoundationPackageManifestTest.php | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/foundation/src/PackageManifest.php b/src/foundation/src/PackageManifest.php index 0ceba755b..6c3653139 100644 --- a/src/foundation/src/PackageManifest.php +++ b/src/foundation/src/PackageManifest.php @@ -287,7 +287,13 @@ public static function rootHypervelExtra(Filesystem $files, string $basePath, st return null; } - return $composer['extra']['hypervel'][$key] ?? null; + $hypervel = $composer['extra']['hypervel'] ?? null; + + if (! is_array($hypervel)) { + return null; + } + + return $hypervel[$key] ?? null; } /** diff --git a/tests/Foundation/FoundationPackageManifestTest.php b/tests/Foundation/FoundationPackageManifestTest.php index 17629b420..2f05ec9e4 100644 --- a/tests/Foundation/FoundationPackageManifestTest.php +++ b/tests/Foundation/FoundationPackageManifestTest.php @@ -267,6 +267,20 @@ public function testPackagesToIgnoreFromComposerReturnsEmptyArrayForMalformedCom ); } + 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();