diff --git a/docs/plans/2026-10-14-aop-registration-seal.md b/docs/plans/2026-10-14-aop-registration-seal.md new file mode 100644 index 0000000000..8a37e1f09e --- /dev/null +++ b/docs/plans/2026-10-14-aop-registration-seal.md @@ -0,0 +1,20 @@ +# Close registration before provider boot + +## Design + +Aspect rules and class-map replacements determine generated proxies. Registration after generation can silently miss a target, leave a method unintercepted, or bypass an existing proxy. Enforce the existing provider `register()` contract without adding work to method dispatch. + +- Give `AspectCollector` and `ClassMapManager` a protected static boolean seal, with public `seal()` and `unseal()` methods. Document these worker-wide mutators as boot/test operations. Do not introduce shared traits, testing detection, snapshots, or additional state machinery. +- Check the seal inline in `AspectCollector::setAround()` and `ClassMapManager::add()` before mutation. `register()` already delegates to `setAround()`; no duplicate guard is needed. Throw `InvalidDefinitionException` for aspects and the existing `RuntimeException` for replacements, directing callers to provider `register()`. +- Reopen both registries at the start of `RegisterProviders::bootstrap()` so reloads and refreshes within one test can register providers again. Preserve existing rules/replacements when reopening. Testbench inherits this bootstrapper. +- Seal both registries after successful `GenerateProxies::bootstrap()`, including when there are no aspects or no matching targets. Keep standalone `generate()` reusable and non-sealing. Do not recover failed partial bootstraps. +- Add `AspectManager::clear()` to discard compiled chains. Call it whenever aspect rules are registered, forgotten, or reset; `AspectManager::flushState()` delegates to it. Rule changes own invalidation, so adding/removing aspects between focused test calls cannot retain stale dispatch. `unseal()` only changes the flag. Dispatch and chain execution gain no checks or counters. +- `flushState()` restores each registry to its initial open state. Existing framework cleanup still owns static cleanup. The PHPUnit extension already resets aspect rules before restoring its saved registrations. +- The PHPUnit extension explicitly reopens aspect registration before preparing temporary proxy metadata. ParaTest's parent process has already terminated its setup applications before loading extensions; retaining their closed registration window serves no caller. Keep the existing `finally` rule restoration and open end state. Do not add a seal getter or state-restoration machinery. +- Preserve focused `callWithAspects()` tests after application bootstrap through explicit `AspectCollector::unseal()`. No automatic PHPUnit or Testbench bypass. Normal provider `boot()` registration must fail in tests too. +- When Testbench `refreshApplication()` fails while creating a replacement app, restore the previous test application's container and facade globals, clear resolved facade instances, and rethrow the original exception. Its failed-app cleanup already flushes the replacement; leaving globals pointed there breaks subsequent facade calls and teardown. Limit recovery to failed creation with an existing test application; keep successful route setup outside the catch. +- Update `aop.md`, the advanced-provider section of `providers.md`, and class-map guidance in `packages.md`. Explain the closed boundary and explicit focused-testing opt-in concisely. Remove stale no-op wording and touched comments. This fixes Hypervel-only contracts and needs no Laravel porting-guide entry. + +## Status + +Implementation, verification, and peer review are complete. diff --git a/src/di/src/Aop/AspectCollector.php b/src/di/src/Aop/AspectCollector.php index b2e6b2b3f8..61445f6c5c 100644 --- a/src/di/src/Aop/AspectCollector.php +++ b/src/di/src/Aop/AspectCollector.php @@ -4,16 +4,12 @@ namespace Hypervel\Di\Aop; +use Hypervel\Di\Exceptions\InvalidDefinitionException; use Hypervel\Support\ClassMetadataCache; use ReflectionProperty; /** - * Static registry of aspect class rules and priorities. - * - * Tracks which aspect classes target which classes/methods, - * and their execution priority. Used by ProxyManager to determine - * which classes need proxy generation and by ProxyDispatcher at runtime - * to resolve the aspect pipeline for each method call. + * Worker-wide aspect rules used to generate proxies and compile dispatch chains. */ class AspectCollector { @@ -24,10 +20,13 @@ class AspectCollector */ protected static array $aspectRules = []; + protected static bool $sealed = false; + /** * Register an aspect using its public default targeting rules and priority. * - * Boot-only. Register before proxy generation; loaded classes cannot be rewritten. + * Boot or tests only. Register before application proxy generation, or explicitly + * reopen registration for focused tests. Loaded classes cannot be rewritten. * * @param class-string $aspect */ @@ -53,18 +52,23 @@ public static function register(string $aspect): void /** * Register an aspect with its class targeting rules. * - * Boot-only. Aspect rules persist in a static property used by the + * Boot or tests only. Aspect rules persist in a static property used by the * compile-time proxy generator; runtime use cannot retroactively apply * aspects to already-loaded classes. */ public static function setAround(string $aspect, array $classes, ?int $priority = null): void { + if (static::$sealed) { + throw new InvalidDefinitionException( + "Cannot register aspect [{$aspect}] after proxy generation. " + . 'Register aspects in a service provider\'s register() method, before boot().' + ); + } + $priority ??= 0; - // Merge idempotently: a provider re-registering the same aspect on a repeated - // boot in the same worker must not append duplicate class rules; the proxy - // generator scans every rule against the whole class map, so duplicates make - // each boot slower than the last. + // Repeated boots re-register aspects. Duplicate rules would change the shared + // fingerprint, regenerating every proxy, and wildcards would rescan the class map. $existing = static::$aspectRules[$aspect]['classes'] ?? []; $classes = array_values(array_unique(array_merge($existing, $classes))); @@ -72,6 +76,29 @@ public static function setAround(string $aspect, array $classes, ?int $priority 'priority' => $priority, 'classes' => $classes, ]; + + AspectManager::clear(); + } + + /** + * Close aspect registration after application proxy generation. + * + * Boot-only. Later registrations in this worker are rejected. + */ + public static function seal(): void + { + static::$sealed = true; + } + + /** + * Reopen aspect registration while retaining the registered rules. + * + * Boot or tests only. This permits changes to worker-wide rules for a new + * application bootstrap or focused tests; it does not rewrite loaded classes. + */ + public static function unseal(): void + { + static::$sealed = false; } /** @@ -91,6 +118,8 @@ public static function hasAspects(): bool public static function forgetAspect(string $aspect): void { unset(static::$aspectRules[$aspect]); + + AspectManager::clear(); } /** @@ -143,5 +172,7 @@ public static function getRules(): array public static function flushState(): void { static::$aspectRules = []; + static::unseal(); + AspectManager::clear(); } } diff --git a/src/di/src/Aop/AspectManager.php b/src/di/src/Aop/AspectManager.php index 835c244a42..3eeee0d7fc 100644 --- a/src/di/src/Aop/AspectManager.php +++ b/src/di/src/Aop/AspectManager.php @@ -36,11 +36,22 @@ public static function set(string $class, string $method, Closure $value): void static::$chains[$class][$method] = $value; } + /** + * Clear the compiled aspect chains. + * + * Boot or tests only. Clearing this worker-wide cache makes subsequent + * calls compile their chains from the current aspect rules. + */ + public static function clear(): void + { + static::$chains = []; + } + /** * Flush all static state. */ public static function flushState(): void { - static::$chains = []; + static::clear(); } } diff --git a/src/di/src/Bootstrap/GenerateProxies.php b/src/di/src/Bootstrap/GenerateProxies.php index db5b84fd38..68e5b6fd47 100644 --- a/src/di/src/Bootstrap/GenerateProxies.php +++ b/src/di/src/Bootstrap/GenerateProxies.php @@ -23,8 +23,8 @@ * * Runs after all service providers have been registered (so all * aspects() calls have executed) and before boot() (so no targeted - * classes have been instantiated yet). No-ops when no aspects are - * registered. + * classes have been instantiated yet). Closes aspect and class map registration + * even when no proxies need to be generated. */ class GenerateProxies { @@ -47,6 +47,9 @@ public function bootstrap(ApplicationContract $app): void if (AspectCollector::hasAspects()) { $this->generate($app->bootstrapPath('cache/aop')); } + + AspectCollector::seal(); + ClassMapManager::seal(); } /** diff --git a/src/di/src/ClassMap/ClassMapManager.php b/src/di/src/ClassMap/ClassMapManager.php index 749e67011c..fe85db97b2 100644 --- a/src/di/src/ClassMap/ClassMapManager.php +++ b/src/di/src/ClassMap/ClassMapManager.php @@ -25,6 +25,8 @@ class ClassMapManager protected static ?ClassLoader $loader = null; + protected static bool $sealed = false; + /** * Add class map entries and apply them to the Composer autoloader. * @@ -32,12 +34,19 @@ class ClassMapManager * Fails if a target is already loaded from a different source. * * Boot-only. Class-map overrides mutate the worker's Composer autoloader and - * must be registered before any target class is autoloaded. + * must be registered before application proxy generation and target loading. * * @param array $map */ public static function add(array $map): void { + if (static::$sealed) { + throw new RuntimeException( + 'Cannot register class map overrides after proxy generation. ' + . 'Register overrides in a service provider\'s register() method, before boot().' + ); + } + foreach ($map as $class => $path) { if (class_exists($class, false) || interface_exists($class, false) || trait_exists($class, false)) { $reflection = new ReflectionClass($class); @@ -67,6 +76,27 @@ public static function add(array $map): void static::$loader->addClassMap($map); } + /** + * Close class map registration after application proxy generation. + * + * Boot-only. Later registrations in this worker are rejected. + */ + public static function seal(): void + { + static::$sealed = true; + } + + /** + * Reopen class map registration while retaining the registered overrides. + * + * Boot or tests only. This permits worker-wide autoloader changes for a new + * application bootstrap; already-loaded classes cannot be replaced. + */ + public static function unseal(): void + { + static::$sealed = false; + } + /** * Publish generated replacements with the lifetime of their source overrides. * @@ -105,5 +135,6 @@ public static function flushState(): void static::$loader?->unregister(); static::$loader = null; static::$entries = []; + static::unseal(); } } diff --git a/src/docs/aop.md b/src/docs/aop.md index da011eaeca..83492455e2 100644 --- a/src/docs/aop.md +++ b/src/docs/aop.md @@ -217,7 +217,7 @@ $this->aspects(ProfileReports::class, TraceHttpRequests::class); ``` > [!WARNING] -> Aspects must be registered during `register`, before the target classes are loaded. Runtime registration cannot retroactively apply aspects to classes that have already been loaded or proxied. +> Aspects must be registered during `register`, before the target classes are loaded. Registration closes after the application's proxy generation step. Registering an aspect during `boot` or later throws an exception. ## Proxy Generation @@ -226,7 +226,7 @@ Hypervel generates AOP proxy classes automatically during application bootstrap. Target classes must not be loaded before proxy generation. Bind factories in your providers' `register` methods and create their instances during `boot` or later. Hypervel reports an error if a target was loaded too early, or if an already-loaded proxy cannot apply the current method rules. Restart workers after changing aspect rules. -If no aspects have been registered, proxy generation does nothing. Existing proxies are reused only while their content fingerprint still matches the source path and contents, aspect rules, registered AST visitors, generator implementation, PHP parser, and PHP version. Changes to any of those inputs regenerate the affected proxy during the next application bootstrap. +If no aspects have been registered, no proxy files are generated, but registration still closes. Existing proxies are reused only while their content fingerprint still matches the source path and contents, aspect rules, registered AST visitors, generator implementation, PHP parser, and PHP version. Changes to any of those inputs regenerate the affected proxy during the next application bootstrap. Each targeted source file must declare exactly one named class, interface, trait, or enum. Nested anonymous classes are supported, but multiple named class-like declarations should be split into separate files before applying an aspect. Methods that return by reference cannot be intercepted because the aspect pipeline returns values rather than reference identities. @@ -268,6 +268,7 @@ class ProfileReportsTest extends TestCase public function test_it_runs_the_aspect(): void { + AspectCollector::unseal(); AspectCollector::setAround(ProfileReports::class, [ ReportService::class . '::generate', ]); @@ -283,4 +284,6 @@ class ProfileReportsTest extends TestCase } ``` +The `unseal` method reopens registration for this test while keeping the application's existing aspects. It does not change classes that have already been loaded, so only use it in tests. Aspects registered by a test are removed before the next test runs. + The `callWithAspects` method expects an object that has not already been proxied. If the object is already proxied, call the method normally instead. diff --git a/src/docs/packages.md b/src/docs/packages.md index e5138236ab..407e885e82 100644 --- a/src/docs/packages.md +++ b/src/docs/packages.md @@ -319,7 +319,7 @@ public function register(): void The replacement file should define the original class name. In the example above, Composer is still being asked to load `VendorPackage\Client`; Hypervel simply tells Composer to load that class from your replacement file instead of the dependency's original file. The replacement should remain compatible with the original class's public API, since other code will still interact with it as `VendorPackage\Client`. -Class map overrides take effect immediately and must be registered before the target class, interface, or trait has been loaded. Register them in the service provider's `register` method before resolving services or referencing classes that may load the target. Repeating an override for the same source file is safe, including when Testbench boots another application. Attempting to replace a loaded target with a different source file throws an exception. +Class map overrides take effect immediately and must be registered before the target class, interface, or trait has been loaded. Register them in the service provider's `register` method before resolving services or referencing classes that may load the target. Registration closes after proxy generation; registering an override during `boot` or later throws an exception. Repeating an override for the same source file during registration is safe, including when Testbench boots another application. Attempting to replace a loaded target with a different source file throws an exception. > [!WARNING] > Class map overrides affect the worker's autoloader for the lifetime of the process. They should only be used for boot-time package integration, not request-specific behavior. diff --git a/src/docs/providers.md b/src/docs/providers.md index 114df4099e..46fc750a77 100644 --- a/src/docs/providers.md +++ b/src/docs/providers.md @@ -244,7 +244,7 @@ When this method returns `false`, the provider's `register` and `boot` methods w ### Advanced Provider APIs -Package service providers may also register AOP aspects and class map overrides. These methods should be called within the `register` method before the target classes are loaded: +Package service providers may also register AOP aspects and class map overrides. Call these methods within `register`, before the target classes are loaded. Registration closes after proxy generation, so calls during `boot` or later throw an exception: ```php use App\Aspects\TraceRequests; diff --git a/src/foundation/src/Bootstrap/RegisterProviders.php b/src/foundation/src/Bootstrap/RegisterProviders.php index 0721584920..7d9dfbce34 100644 --- a/src/foundation/src/Bootstrap/RegisterProviders.php +++ b/src/foundation/src/Bootstrap/RegisterProviders.php @@ -5,6 +5,8 @@ namespace Hypervel\Foundation\Bootstrap; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Di\Aop\AspectCollector; +use Hypervel\Di\ClassMap\ClassMapManager; class RegisterProviders { @@ -25,6 +27,10 @@ class RegisterProviders */ public function bootstrap(ApplicationContract $app): void { + // Each application opens a registration window; GenerateProxies closes it before provider boot. + AspectCollector::unseal(); + ClassMapManager::unseal(); + if (! $app->bound('config_loaded_from_cache') || $app->make('config_loaded_from_cache') === false) { $this->mergeAdditionalProviders($app); diff --git a/src/foundation/src/Testing/Concerns/InteractsWithAop.php b/src/foundation/src/Testing/Concerns/InteractsWithAop.php index 2d9c791728..dd58389b85 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithAop.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithAop.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation\Testing\Concerns; +use Hypervel\Di\Aop\AspectCollector; use Hypervel\Di\Aop\ProxyDispatcher; use Hypervel\Di\Aop\ProxyMarker; use LogicException; @@ -43,6 +44,9 @@ protected function isAopProxied(object $instance): bool * The original method is called via reflection at the end of the pipeline. * The instance must not already be proxied. * + * After application bootstrap, call {@see AspectCollector::unseal()} before + * registering aspects for this helper. + * * @param object $instance The object instance to call the method on * @param string $method The method name to intercept * @param array $arguments Method arguments as key => value pairs (parameter names as keys) diff --git a/src/testbench/src/TestCase.php b/src/testbench/src/TestCase.php index dd0304387e..83c91d9154 100644 --- a/src/testbench/src/TestCase.php +++ b/src/testbench/src/TestCase.php @@ -5,14 +5,17 @@ namespace Hypervel\Testbench; use Closure; +use Hypervel\Container\Container; use Hypervel\Coordinator\Constants; use Hypervel\Coordinator\CoordinatorManager; use Hypervel\Filesystem\Filesystem; +use Hypervel\Foundation\Application; use Hypervel\Foundation\Testing\DatabaseMigrations; use Hypervel\Foundation\Testing\DatabaseTransactions; use Hypervel\Foundation\Testing\DatabaseTruncation; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Foundation\Testing\TestCase as BaseTestCase; +use Hypervel\Support\Facades\Facade; use Hypervel\Testbench\Attributes\ResetRefreshDatabaseState; use Hypervel\Testbench\Attributes\WithMigration; use ReflectionMethod; @@ -189,7 +192,22 @@ protected function prepareDatabaseTruncationForMethod(array $uses): void */ protected function refreshApplication(): void { - $this->app = $this->createApplication(); + try { + $this->app = $this->createApplication(); + } catch (Throwable $exception) { + // Failed creation flushes the replacement. Restore the previous container + // and facades so framework teardown can run. + if ($this->app !== null) { + /** @var Application $application */ + $application = $this->app; + + Container::setInstance($application); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($application); + } + + throw $exception; + } // Setup routes after application is created (providers are booted). $this->setUpApplicationRoutes($this->app); diff --git a/src/testing/src/PHPUnit/AfterEachTestExtension.php b/src/testing/src/PHPUnit/AfterEachTestExtension.php index 66a1cb50d2..b69a9a5dbf 100644 --- a/src/testing/src/PHPUnit/AfterEachTestExtension.php +++ b/src/testing/src/PHPUnit/AfterEachTestExtension.php @@ -52,6 +52,9 @@ protected function generateGuzzleProxies(): void $directory = ParallelTesting::tempDir('guzzle-proxies'); try { + // ParaTest boots and terminates process-setup applications before loading extensions. + AspectCollector::unseal(); + foreach ([ PromiseConstructionAspect::class, PromiseOperationAspect::class, diff --git a/tests/Di/Aop/AspectCollectorTest.php b/tests/Di/Aop/AspectCollectorTest.php index 8c82358742..3cf4ff8e92 100644 --- a/tests/Di/Aop/AspectCollectorTest.php +++ b/tests/Di/Aop/AspectCollectorTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Di\Aop; use Hypervel\Di\Aop\AspectCollector; +use Hypervel\Di\Exceptions\InvalidDefinitionException; +use Hypervel\Tests\Di\Fixtures\Aspect\TestAspect; use Hypervel\Tests\TestCase; class AspectCollectorTest extends TestCase @@ -69,6 +71,51 @@ public function testGetPriorityReturnsZeroForUnregisteredAspect(): void $this->assertSame(0, AspectCollector::getPriority('NonExistent')); } + public function testSealedCollectorRejectsRegistration(): void + { + AspectCollector::seal(); + + $this->expectException(InvalidDefinitionException::class); + $this->expectExceptionMessageIsOrContains('register()'); + + AspectCollector::register(TestAspect::class); + } + + public function testSealedCollectorRejectsRuleChanges(): void + { + AspectCollector::setAround(TestAspect::class, ['App\Foo::bar'], 5); + $rules = AspectCollector::getRules(); + AspectCollector::seal(); + + try { + AspectCollector::setAround(TestAspect::class, ['App\Baz::qux'], 10); + $this->fail('Sealed aspect rules must reject changes.'); + } catch (InvalidDefinitionException $exception) { + $this->assertStringContainsString(TestAspect::class, $exception->getMessage()); + $this->assertSame($rules, AspectCollector::getRules()); + } + } + + public function testUnsealPreservesRulesAndAllowsRegistration(): void + { + AspectCollector::setAround('FirstAspect', ['App\Foo::bar']); + AspectCollector::seal(); + AspectCollector::unseal(); + AspectCollector::setAround('SecondAspect', ['App\Foo::bar']); + + $this->assertSame(['FirstAspect', 'SecondAspect'], array_keys(AspectCollector::getRules())); + } + + public function testFlushStateReopensRegistration(): void + { + AspectCollector::setAround('FirstAspect', ['App\Foo::bar']); + AspectCollector::seal(); + AspectCollector::flushState(); + AspectCollector::register(TestAspect::class); + + $this->assertSame([TestAspect::class], array_keys(AspectCollector::getRules())); + } + public function testGetRuleReturnsEmptyForUnregisteredAspect(): void { $this->assertSame([], AspectCollector::getRule('NonExistent')); diff --git a/tests/Di/Aop/ProxyDispatcherTest.php b/tests/Di/Aop/ProxyDispatcherTest.php index 50ac9b8164..0b39c7b978 100644 --- a/tests/Di/Aop/ProxyDispatcherTest.php +++ b/tests/Di/Aop/ProxyDispatcherTest.php @@ -59,8 +59,6 @@ public function testPublishesAndReusesTheCompletePrioritizedAspectChain(): void )); $chain = AspectManager::get(ProxyDispatcherTarget::class, 'number'); - AspectCollector::flushState(); - $this->assertSame(5, ProxyDispatcher::dispatch( ProxyDispatcherTarget::class, 'number', @@ -71,6 +69,35 @@ public function testPublishesAndReusesTheCompletePrioritizedAspectChain(): void $this->assertSame($chain, AspectManager::get(ProxyDispatcherTarget::class, 'number')); } + public function testRuleChangesInvalidatePreviouslyCompiledChains(): void + { + $target = new ProxyDispatcherTarget; + $dispatch = fn (): mixed => ProxyDispatcher::dispatch( + ProxyDispatcherTarget::class, + 'number', + $this->arguments(['value' => 2]), + $target->number(...), + $target + ); + + $this->assertSame(2, $dispatch()); + + AspectCollector::setAround(DispatcherIncrementAspect::class, [ProxyDispatcherTarget::class . '::number'], 20); + $this->assertSame(3, $dispatch()); + + AspectCollector::setAround(DispatcherDoubleAspect::class, [ProxyDispatcherTarget::class . '::number'], 10); + $this->assertSame(5, $dispatch()); + + AspectCollector::setAround(DispatcherIncrementAspect::class, [ProxyDispatcherTarget::class . '::number'], 5); + $this->assertSame(6, $dispatch()); + + AspectCollector::forgetAspect(DispatcherIncrementAspect::class); + $this->assertSame(4, $dispatch()); + + AspectCollector::flushState(); + $this->assertSame(2, $dispatch()); + } + public function testAspectMayShortCircuitWithoutImplementingAroundInterface(): void { AspectCollector::setAround(NoProcessAspect::class, [ProxyDispatcherTarget::class . '::number']); diff --git a/tests/Di/Bootstrap/GenerateProxiesTest.php b/tests/Di/Bootstrap/GenerateProxiesTest.php index 7accfa81a9..717b62408e 100644 --- a/tests/Di/Bootstrap/GenerateProxiesTest.php +++ b/tests/Di/Bootstrap/GenerateProxiesTest.php @@ -8,7 +8,6 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Di\Aop\AbstractAspect; use Hypervel\Di\Aop\AspectCollector; -use Hypervel\Di\Aop\AspectManager; use Hypervel\Di\Aop\AstVisitorRegistry; use Hypervel\Di\Aop\ProceedingJoinPoint; use Hypervel\Di\Aop\ProxyCallVisitor; @@ -70,21 +69,57 @@ protected function tearDown(): void parent::tearDown(); } - public function testNoOpsWhenNoAspectsRegistered(): void + #[DataProvider('bootstrapAspectCases')] + public function testBootstrapClosesRegistration(string $aspects): void { - $app = m::mock(ApplicationContract::class); - $app->shouldNotReceive('storagePath'); + $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir) use ($aspects): void { + $app = m::mock(ApplicationContract::class); - (new GenerateProxies)->bootstrap($app); + if ($aspects === 'none') { + $app->shouldNotReceive('bootstrapPath'); + } else { + AspectCollector::setAround(OverrideSourceAspect::class, [ + $aspects === 'matching' ? $className . '::value' : 'Missing\Target', + ]); + $app->shouldReceive('bootstrapPath')->with('cache/aop')->andReturn($proxyDir); + } + + (new IsolatedProxyGenerator)->bootstrap($app); + + $this->assertSame($aspects !== 'none', AstVisitorRegistry::exists(ProxyCallVisitor::class)); + if ($aspects === 'matching') { + $this->assertSame('intercepted:original-source', (new $className)->value()); + } + + try { + AspectCollector::setAround('LateAspect', [$className . '::value']); + $this->fail('Application bootstrap must close aspect registration.'); + } catch (InvalidDefinitionException $exception) { + $this->assertStringContainsString('register()', $exception->getMessage()); + } - $this->assertFalse(AstVisitorRegistry::exists(ProxyCallVisitor::class)); + try { + ClassMapManager::add([$className => $overrideFile]); + $this->fail('Application bootstrap must close class map registration.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('after proxy generation', $exception->getMessage()); + } + }); + } + + /** + * Provide application bootstraps with and without proxy targets. + */ + public static function bootstrapAspectCases(): array + { + return [['none'], ['unmatched'], ['matching']]; } public function testRegistersProxyCallVisitorWhenAspectsExist(): void { AspectCollector::setAround('SomeAspect', ['SomeNonExistentClass']); - $this->bootstrapProxies($this->tempDirectory . '/aop'); + (new IsolatedProxyGenerator)->generate($this->tempDirectory . '/aop'); $this->assertTrue(AstVisitorRegistry::exists(ProxyCallVisitor::class)); } @@ -149,7 +184,7 @@ public function testDoesNotRegisterProxyCallVisitorTwice(): void AspectCollector::setAround('SomeAspect', ['SomeNonExistentClass']); AstVisitorRegistry::insert(ProxyCallVisitor::class); - $this->bootstrapProxies($this->tempDirectory . '/aop'); + (new IsolatedProxyGenerator)->generate($this->tempDirectory . '/aop'); $queue = clone AstVisitorRegistry::getQueue(); $count = 0; @@ -168,13 +203,13 @@ public function testRegeneratesDeletedProxyFilesFromTheApplicationSourceMap(): v $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround('TestAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $this->assertFileExists($proxyFile); $this->filesystem->deleteDirectory($proxyDir); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertFileExists($proxyFile); $this->assertStringContainsString('original-source', $this->filesystem->get($proxyFile)); @@ -188,7 +223,7 @@ public function testPublishesProxiesWithoutChangingTheApplicationClassMap(): voi $originalMap = $this->loader->getClassMap(); AspectCollector::setAround('TestAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertSame($originalMap, $this->loader->getClassMap()); $this->assertSame([$className], array_keys(IsolatedProxyGenerator::getProxyMap())); @@ -242,7 +277,7 @@ public function testSelectsMixedExactAndWildcardRulesInOrderWithoutDuplicates(): $prefix . '\Missing*', ]); - $this->bootstrapProxies($this->tempDirectory . '/aop'); + (new IsolatedProxyGenerator)->generate($this->tempDirectory . '/aop'); $this->assertSame( [$classes[1], $classes[0]], @@ -264,22 +299,22 @@ public function testRejectsTargetsLoadedBeforeProxyGeneration(): void $this->expectException(InvalidDefinitionException::class); $this->expectExceptionMessage($className); $this->expectExceptionMessage('register()'); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); }); } - public function testLoadedProxiesAllowRepeatedBootsSubsetRulesAndUnrelatedAspects(): void + public function testLoadedProxiesAllowRegenerationSubsetRulesAndUnrelatedAspects(): void { $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround('TestAspect', [$className]); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertTrue(class_exists($className)); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); AspectCollector::forgetAspect('TestAspect'); AspectCollector::setAround('TestAspect', [$className . '::val*']); AspectCollector::setAround('UnrelatedAspect', ['Missing\UnrelatedTarget']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertNotSame($sourceFile, (new ReflectionMethod($className, 'value'))->getFileName()); }); @@ -289,13 +324,13 @@ public function testLoadedProxyRejectsAdditionalMethodRequirements(): void { $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround('TestAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertTrue(class_exists($className)); AspectCollector::setAround('TestAspect', [$className]); $this->expectException(InvalidDefinitionException::class); $this->expectExceptionMessage($className . '::other'); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); }); } @@ -304,7 +339,7 @@ public function testRegeneratesPsr4ProxiesWithoutChangingSourceResolution(): voi $this->withPsr4ProxyFixture(function (string $className, string $proxyDir): void { AspectCollector::setAround('TestAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $this->assertFileExists($proxyFile); @@ -314,7 +349,7 @@ public function testRegeneratesPsr4ProxiesWithoutChangingSourceResolution(): voi $this->assertNotSame($source, $proxyFile); $this->filesystem->deleteDirectory($proxyDir); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertFileExists($proxyFile); $this->assertStringContainsString('psr4-source', $this->filesystem->get($proxyFile)); @@ -326,12 +361,12 @@ public function testRegeneratesProxyWhenSourceChangesWithoutAnMtimeChange(): voi $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround('TestAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $sourceMtime = filemtime($sourceFile); $this->writeProxySource($sourceFile, $className, 'same-mtime-source'); touch($sourceFile, $sourceMtime); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $this->assertStringContainsString('same-mtime-source', $this->filesystem->get($proxyFile)); @@ -343,12 +378,12 @@ public function testRegeneratesProxyWhenSourcePathChanges(): void $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround('TestAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->writeProxySource($overrideFile, $className, 'override-source'); touch($overrideFile, (int) filemtime($sourceFile) - 100); $this->loader->addClassMap([$className => $overrideFile]); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $this->assertStringContainsString('override-source', $this->filesystem->get($proxyFile)); @@ -360,12 +395,12 @@ public function testReplacementProxyDoesNotOverwriteTheOrdinaryProxy(): void $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround(OverrideSourceAspect::class, [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $ordinaryProxy = IsolatedProxyGenerator::getProxyMap()[$className]; $this->writeProxySource($overrideFile, $className, 'override-source'); ClassMapManager::add([$className => $overrideFile]); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertSame('intercepted:override-source', (new $className)->value()); $this->assertStringContainsString('original-source', $this->filesystem->get($ordinaryProxy)); @@ -386,16 +421,15 @@ public function testUnloadedReplacementProxyDoesNotSurviveReset(bool $ordinaryPr AspectCollector::setAround(OverrideSourceAspect::class, [$className . '::value']); if ($ordinaryProxyExists) { - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); } $this->writeProxySource($overrideFile, $className, 'override-source'); ClassMapManager::add([$className => $overrideFile]); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); ClassMapManager::flushState(); AspectCollector::flushState(); - AspectManager::flushState(); $this->assertSame('original-source', (new $className)->value()); }); @@ -415,12 +449,12 @@ public function testLoadedOverrideProxyAcceptsOnlyItsOriginalSourceAfterReset(): $this->writeProxySource($overrideFile, $className, 'override-source'); ClassMapManager::add([$className => $overrideFile]); AspectCollector::setAround(OverrideSourceAspect::class, [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertSame('intercepted:override-source', (new $className)->value()); ClassMapManager::flushState(); ClassMapManager::add([$className => $overrideFile]); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertSame('intercepted:override-source', (new $className)->value()); @@ -431,17 +465,17 @@ public function testLoadedOverrideProxyAcceptsOnlyItsOriginalSourceAfterReset(): }); } - public function testProxyKeepsPriorityOverAnOverrideRegisteredOnTheNextBoot(): void + public function testProxyKeepsPriorityOverAnOverrideRegisteredAfterReset(): void { $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { $this->writeProxySource($overrideFile, $className, 'override-source'); ClassMapManager::add([$className => $overrideFile]); AspectCollector::setAround(OverrideSourceAspect::class, [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); ClassMapManager::flushState(); ClassMapManager::add([$className => $overrideFile]); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertSame('intercepted:override-source', (new $className)->value()); }); @@ -452,12 +486,12 @@ public function testRegeneratesProxyWhenAspectRulesChange(): void $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround('FirstAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $first = $this->filesystem->get($proxyFile); AspectCollector::setAround('SecondAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertNotSame($first, $this->filesystem->get($proxyFile)); }); @@ -470,14 +504,14 @@ public function testRegeneratesProxyWhenVisitorOrderChanges(): void AstVisitorRegistry::insert(FingerprintVisitorOne::class, 20); AstVisitorRegistry::insert(FingerprintVisitorTwo::class, 10); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $first = $this->filesystem->get($proxyFile); AstVisitorRegistry::flushState(); AstVisitorRegistry::insert(FingerprintVisitorOne::class, 10); AstVisitorRegistry::insert(FingerprintVisitorTwo::class, 20); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertNotSame($first, $this->filesystem->get($proxyFile)); }); @@ -499,7 +533,7 @@ public function testUsesCollisionFreeEncodedProxyFilenames(): void ]); AspectCollector::setAround('TestAspect', [$firstClass, $secondClass]); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $firstProxy = IsolatedProxyGenerator::getProxyMap()[$firstClass]; $secondProxy = IsolatedProxyGenerator::getProxyMap()[$secondClass]; @@ -514,7 +548,7 @@ public function testReusesAProxyFromItsFingerprintHeaderWithoutParsingItsBody(): { $this->withProxyFixture(function (string $className, string $sourceFile, string $overrideFile, string $proxyDir): void { AspectCollector::setAround('TestAspect', [$className . '::value']); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $proxyFile = IsolatedProxyGenerator::getProxyMap()[$className]; $lines = file($proxyFile); @@ -522,7 +556,7 @@ public function testReusesAProxyFromItsFingerprintHeaderWithoutParsingItsBody(): $cachedBody = $lines[0] . $lines[1] . "cached-body-is-not-parsed\n"; $this->filesystem->put($proxyFile, $cachedBody); - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); $this->assertSame($cachedBody, $this->filesystem->get($proxyFile)); }); @@ -553,7 +587,7 @@ class ExtraClass {} $exception = null; try { - $this->bootstrapProxies($proxyDir); + (new IsolatedProxyGenerator)->generate($proxyDir); } catch (Throwable $throwable) { $exception = $throwable; } @@ -614,19 +648,6 @@ private function withPsr4ProxyFixture(callable $callback): void $callback($className, $proxyDir); } - /** - * Bootstrap proxy generation against the given proxy directory. - */ - private function bootstrapProxies(string $proxyDir): void - { - $app = m::mock(ApplicationContract::class); - $app->shouldReceive('bootstrapPath') - ->with('cache/aop') - ->andReturn($proxyDir); - - (new IsolatedProxyGenerator)->bootstrap($app); - } - /** * Write a source file used for proxy generation. */ diff --git a/tests/Di/ClassMap/ClassMapManagerTest.php b/tests/Di/ClassMap/ClassMapManagerTest.php index bf7f5a24da..b81d67bcbd 100644 --- a/tests/Di/ClassMap/ClassMapManagerTest.php +++ b/tests/Di/ClassMap/ClassMapManagerTest.php @@ -88,6 +88,49 @@ public function testLoadedReplacementCanBeRegisteredAgainAfterReset(): void $this->assertSame('repeated', (new $class)->value()); } + public function testSealedRegistryRejectsOverridesWithoutChangingAutoloading(): void + { + $class = __NAMESPACE__ . '\Sealed' . bin2hex(random_bytes(4)); + $original = $this->directory . '/Original.php'; + $replacement = $this->directory . '/Replacement.php'; + $this->writeSource($original, $class, 'original'); + $this->writeSource($replacement, $class, 'replacement'); + $this->isolatedLoader->addClassMap([$class => $original]); + ClassMapManager::seal(); + + try { + ClassMapManager::add([$class => $replacement]); + $this->fail('Sealed class maps must reject overrides.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('register()', $exception->getMessage()); + $this->assertSame([], ClassMapManager::getEntries()); + $this->assertSame('original', (new $class)->value()); + } + } + + public function testUnsealPreservesOverridesAndAllowsRegistration(): void + { + ClassMapManager::add(['Fake\First' => '/tmp/first.php']); + ClassMapManager::seal(); + ClassMapManager::unseal(); + ClassMapManager::add(['Fake\Second' => '/tmp/second.php']); + + $this->assertSame([ + 'Fake\First' => '/tmp/first.php', + 'Fake\Second' => '/tmp/second.php', + ], ClassMapManager::getEntries()); + } + + public function testFlushStateReopensRegistration(): void + { + ClassMapManager::add(['Fake\First' => '/tmp/first.php']); + ClassMapManager::seal(); + ClassMapManager::flushState(); + ClassMapManager::add(['Fake\Second' => '/tmp/second.php']); + + $this->assertSame(['Fake\Second' => '/tmp/second.php'], ClassMapManager::getEntries()); + } + public function testAddThrowsWhenClassAlreadyLoaded(): void { // This test class itself is already loaded diff --git a/tests/Foundation/Bootstrap/AspectRegistrationTest.php b/tests/Foundation/Bootstrap/AspectRegistrationTest.php new file mode 100644 index 0000000000..c52b95e123 --- /dev/null +++ b/tests/Foundation/Bootstrap/AspectRegistrationTest.php @@ -0,0 +1,133 @@ +providers; + } + + #[DataProvider('applicationRestarts')] + public function testApplicationRestartsReopenAndResealRegistration(string $restart): void + { + $firstApplication = $this->app; + $this->assertSame('outer(inner(value))', $this->waitForValue()); + + $this->providers = array_reverse($this->providers); + $this->{$restart}(); + + $this->assertNotSame($firstApplication, $this->app); + $this->assertSame('outer(inner(value))', $this->waitForValue()); + $this->assertSame([ + RegistrationReplacement::class => __FILE__, + ], ClassMapManager::getEntries()); + + try { + AspectCollector::register(OuterRegistrationAspect::class); + $this->fail('A fresh application must close aspect registration again.'); + } catch (InvalidDefinitionException $exception) { + $this->assertStringContainsString('register()', $exception->getMessage()); + } + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageIsOrContains('after proxy generation'); + ClassMapManager::add([RegistrationReplacement::class => __FILE__]); + } + + /** + * Provide the supported ways to recreate the test application. + */ + public static function applicationRestarts(): array + { + return [['reloadApplication'], ['refreshApplication']]; + } + + /** + * Exercise the real generated promise proxy. + */ + private function waitForValue(): string + { + $promise = new Promise; + $promise->resolve('value'); + + return $promise->wait(); + } +} + +class OuterRegistrationProvider extends ServiceProvider +{ + /** + * Register the outer aspect and a repeatable class map entry. + */ + public function register(): void + { + $this->aspects(OuterRegistrationAspect::class); + $this->classMap([RegistrationReplacement::class => __FILE__]); + } +} + +class InnerRegistrationProvider extends ServiceProvider +{ + /** + * Register the inner aspect. + */ + public function register(): void + { + $this->aspects(InnerRegistrationAspect::class); + } +} + +class OuterRegistrationAspect extends AbstractAspect +{ + public array $classes = [Promise::class . '::wait']; + + public ?int $priority = 20; + + /** + * Wrap the promise result with the outer marker. + */ + public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed + { + return 'outer(' . $proceedingJoinPoint->process() . ')'; + } +} + +class InnerRegistrationAspect extends AbstractAspect +{ + public array $classes = [Promise::class . '::wait']; + + public ?int $priority = 10; + + /** + * Wrap the promise result with the inner marker. + */ + public function process(ProceedingJoinPoint $proceedingJoinPoint): mixed + { + return 'inner(' . $proceedingJoinPoint->process() . ')'; + } +} + +class RegistrationReplacement +{ +} diff --git a/tests/Foundation/Bootstrap/LateAspectRegistrationTest.php b/tests/Foundation/Bootstrap/LateAspectRegistrationTest.php new file mode 100644 index 0000000000..81403ddc92 --- /dev/null +++ b/tests/Foundation/Bootstrap/LateAspectRegistrationTest.php @@ -0,0 +1,61 @@ +expectException(InvalidDefinitionException::class); + $this->expectExceptionMessageIsOrContains('Register aspects in a service provider\'s register()'); + + Application::create( + (string) default_skeleton_path(), + options: ['extra' => ['providers' => [LateAspectRegistrationProvider::class]]], + ); + } + + public function testProviderBootCannotRegisterClassMapOverrides(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageIsOrContains('Register overrides in a service provider\'s register()'); + + Application::create( + (string) default_skeleton_path(), + options: ['extra' => ['providers' => [LateClassMapRegistrationProvider::class]]], + ); + } +} + +class LateAspectRegistrationProvider extends ServiceProvider +{ + /** + * Attempt to register an aspect during provider boot. + */ + public function boot(): void + { + $this->aspects(TestAspect::class); + } +} + +class LateClassMapRegistrationProvider extends ServiceProvider +{ + /** + * Attempt to register a class map during provider boot. + */ + public function boot(): void + { + $this->classMap([self::class => __FILE__]); + } +} diff --git a/tests/Foundation/Testing/Concerns/InteractsWithAopTest.php b/tests/Foundation/Testing/Concerns/InteractsWithAopTest.php index dbf9e73b87..fb3d354b20 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithAopTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithAopTest.php @@ -18,6 +18,16 @@ class InteractsWithAopTest extends TestCase { use InteractsWithAop; + /** + * Allow focused aspect registration after the test application has booted. + */ + protected function setUp(): void + { + parent::setUp(); + + AspectCollector::unseal(); + } + public function testCallWithAspectsRunsMatchingAspect(): void { AspectCollector::setAround(UppercaseGreetingAspect::class, [InteractsWithAopTarget::class . '::greet']); diff --git a/tests/Testbench/RefreshApplicationTest.php b/tests/Testbench/RefreshApplicationTest.php new file mode 100644 index 0000000000..19a7076fdc --- /dev/null +++ b/tests/Testbench/RefreshApplicationTest.php @@ -0,0 +1,65 @@ +bootstrapFailure === null) { + return []; + } + + $app->instance('test.refresh_failure', $this->bootstrapFailure); + + return [FailingRefreshApplicationProvider::class]; + } + + public function testFailedRefreshRestoresContainerAndFacadesForTeardown(): void + { + $previousApplication = $this->app; + $previousConfig = Config::getFacadeRoot(); + $this->bootstrapFailure = new RuntimeException('Replacement application failed to boot.'); + + try { + $this->refreshApplication(); + $this->fail('The replacement application must fail to boot.'); + } catch (RuntimeException $exception) { + $this->assertSame($this->bootstrapFailure, $exception); + } + + $this->assertSame($previousApplication, $this->app); + $this->assertSame($previousApplication, Container::getInstance()); + $this->assertSame($previousApplication, Facade::getFacadeApplication()); + $this->assertSame($previousApplication, app()); + $this->assertSame($previousConfig, Config::getFacadeRoot()); + } +} + +class FailingRefreshApplicationProvider extends ServiceProvider +{ + /** + * Resolve a facade against the new application before failing its bootstrap. + */ + public function boot(): void + { + Config::get('app.name'); + + throw $this->app->make('test.refresh_failure'); + } +} diff --git a/tests/Testing/PHPUnit/AfterEachTestExtensionTest.php b/tests/Testing/PHPUnit/AfterEachTestExtensionTest.php index bf974ec4c6..f91dc42fe8 100644 --- a/tests/Testing/PHPUnit/AfterEachTestExtensionTest.php +++ b/tests/Testing/PHPUnit/AfterEachTestExtensionTest.php @@ -8,8 +8,10 @@ use GuzzleHttp\Promise\Promise; use Hypervel\Di\Aop\AspectCollector; use Hypervel\Di\Aop\ProxyMethod; +use Hypervel\Di\Exceptions\InvalidDefinitionException; use Hypervel\Filesystem\Filesystem; use Hypervel\Testing\ParallelTesting; +use Hypervel\Testing\PHPUnit\AfterEachTestExtension; use Hypervel\Tests\TestCase; use ReflectionMethod; use Symfony\Component\Process\Process; @@ -85,6 +87,34 @@ public function testGuzzleProxiesArePreparedWithoutActivatingInstrumentation(): $this->assertCount(1, (new ReflectionMethod(Client::class, 'transfer'))->getAttributes(ProxyMethod::class)); } + public function testProxyPreparationAcceptsASealedRegistryAndRestoresItsRules(): void + { + AspectCollector::setAround('SavedAspect', ['Missing\Target']); + $rules = AspectCollector::getRules(); + AspectCollector::seal(); + + (new ReflectionMethod(AfterEachTestExtension::class, 'generateGuzzleProxies')) + ->invoke(new AfterEachTestExtension); + + $this->assertSame($rules, AspectCollector::getRules()); + } + + public function testFailedProxyPreparationRestoresTheOriginalRules(): void + { + AspectCollector::setAround('SavedAspect', [self::class . '::testFailedProxyPreparationRestoresTheOriginalRules']); + $rules = AspectCollector::getRules(); + AspectCollector::seal(); + + try { + (new ReflectionMethod(AfterEachTestExtension::class, 'generateGuzzleProxies')) + ->invoke(new AfterEachTestExtension); + $this->fail('An already-loaded target must fail proxy generation.'); + } catch (InvalidDefinitionException $exception) { + $this->assertStringContainsString(self::class, $exception->getMessage()); + $this->assertSame($rules, AspectCollector::getRules()); + } + } + public function testOutsideGuzzleCallbacksAreDiscardedBetweenTestsAndAtShutdown(): void { $process = $this->runFixture('GuzzleCleanupFixture.php', []);