*/ private array $kernels = []; protected function tearDown(): void { foreach ($this->kernels as $kernel) { try { $kernel->shutdown(); } catch (\LogicException) { // Tests that intentionally leave a scope active terminate it themselves. } } } #[Test] #[TestDox('Kernel boot is idempotent and freezes one registry')] public function bootsOnce(): void { [$kernel, $modules, $registry] = $this->kernel(); $kernel->boot(); $kernel->boot(); self::assertTrue($registry->frozen()); } #[Test] #[TestDox('Beginning execution boots the kernel automatically')] public function bootsOnExecution(): void { [$kernel, $modules] = $this->kernel(); $scope = $kernel->beginExecution(ExecutionDescriptor::cli()); $kernel->terminateExecution($scope, ExecutionOutcome::success()); self::assertTrue($scope->terminated()); } #[Test] #[TestDox('Only one execution scope can be active')] public function rejectsConcurrentScopes(): void { [$kernel] = $this->kernel(); $scope = $kernel->beginExecution(ExecutionDescriptor::cli()); try { $kernel->beginExecution(ExecutionDescriptor::http()); self::fail('Expected the second execution scope to be rejected.'); } catch (\LogicException) { self::assertFalse($scope->terminated()); } finally { $kernel->terminateExecution($scope, ExecutionOutcome::success()); } } #[Test] #[TestDox('Repeated termination does not repeat side effects')] public function terminatesOnce(): void { $processor = new RecordingProcessor(); [$kernel] = $this->kernel($processor); $scope = $kernel->beginExecution(ExecutionDescriptor::cli()); $first = $kernel->terminateExecution($scope, ExecutionOutcome::success()); $second = $kernel->terminateExecution($scope, ExecutionOutcome::success()); self::assertSame(1, $processor->processed); self::assertSame(1, $first->deferredProcessed); self::assertSame(0, $second->deferredProcessed); } #[Test] #[TestDox('Shutdown rejects active execution scopes')] public function rejectsActiveShutdown(): void { [$kernel] = $this->kernel(); $scope = $kernel->beginExecution(ExecutionDescriptor::cli()); try { $kernel->shutdown(); self::fail('Expected active shutdown to be rejected.'); } catch (\LogicException) { self::assertFalse($scope->terminated()); } finally { $kernel->terminateExecution($scope, ExecutionOutcome::success()); } } #[Test] #[TestDox('Deferred failures are reported and the next execution can start')] public function recoversFromDeferredFailure(): void { $processor = new RecordingProcessor(); $processor->failure = new \RuntimeException('Deferred processing failed.'); [$kernel] = $this->kernel($processor); $first = $kernel->beginExecution(ExecutionDescriptor::cli()); $report = $kernel->terminateExecution($first, ExecutionOutcome::success()); $processor->failure = null; $second = $kernel->beginExecution(ExecutionDescriptor::cli()); $kernel->terminateExecution($second, ExecutionOutcome::success()); self::assertCount(1, $report->failures); self::assertTrue($first->terminated()); self::assertTrue($second->terminated()); } /** * @return array{TestKernel, ModuleManager&MockObject, EventListenerRegistry} */ private function kernel(?RecordingProcessor $processor = null): array { $modules = $this->createMock(ModuleManager::class); $modules->expects(self::once())->method('modulesBoot'); $registry = new EventListenerRegistry(); $processor ??= new RecordingProcessor(); $container = new Container(); $container->set(ModuleManager::class, $modules); $container->set(EventListenerRegistry::class, $registry); $container->set(DeferredEventProcessorInterface::class, $processor); $container->set( TenantContext::class, new TenantContext($this->createStub(TenantService::class)), ); $container->set(IdentityContext::class, new IdentityContext()); $container->set(EphemeralCacheInterface::class, $this->createStub(EphemeralCacheInterface::class)); $container->set(PersistentCacheInterface::class, $this->createStub(PersistentCacheInterface::class)); $container->set(BlobCacheInterface::class, $this->createStub(BlobCacheInterface::class)); $kernel = new TestKernel( new KernelOptions(ProjectPaths::resolve(dirname(__DIR__, 4)), 'test'), $container, ); $this->kernels[] = $kernel; return [$kernel, $modules, $registry]; } } final class TestKernel extends Kernel { public function __construct( KernelOptions $options, private readonly Container $testContainer, ) { parent::__construct($options, ['log' => ['driver' => 'null']]); } protected function initializeContainer(): Container { return $this->testContainer; } } final class RecordingProcessor implements DeferredEventProcessorInterface { public int $processed = 0; public ?\Throwable $failure = null; private ?string $active = null; public function beginExecution(string $executionId): void { if ($this->active !== null) { throw new \LogicException('Execution already active.'); } $this->active = $executionId; } public function processDeferred(string $executionId): DeferredProcessingResult { if ($this->failure !== null) { throw $this->failure; } $this->active = null; $this->processed++; return new DeferredProcessingResult(1, 0, false); } public function discardDeferred(string $executionId): void { $this->active = null; } }