createStub(ModuleManager::class); $modules->method('list')->willReturn(new ModuleCollection()); $kernel = $this->kernel(new ConsoleContainer([ModuleManager::class => $modules])); $exitCode = (new ConsoleRuntime($kernel))->run( new ArrayInput(['command' => 'list', '--raw' => true]), new BufferedOutput(), ); self::assertSame(0, $exitCode); self::assertSame(1, $kernel->terminations); self::assertTrue($kernel->outcome?->successful); } #[Test] #[TestDox('Console failures terminate before being rethrown')] public function fails(): void { $kernel = $this->kernel(new ConsoleContainer([], new \RuntimeException('Discovery failed.'))); try { (new ConsoleRuntime($kernel))->run( new ArrayInput(['command' => 'list']), new BufferedOutput(), ); self::fail('Expected console discovery to fail.'); } catch (\RuntimeException $error) { self::assertSame('Discovery failed.', $error->getMessage()); } self::assertSame(1, $kernel->terminations); self::assertFalse($kernel->outcome?->successful); } #[Test] #[TestDox('Deferred termination failures do not replace command exit codes')] public function preservesExitCodes(): void { $modules = $this->createStub(ModuleManager::class); $modules->method('list')->willReturn(new ModuleCollection()); $kernel = $this->kernel(new ConsoleContainer([ModuleManager::class => $modules])); $kernel->terminationReport = new TerminationReport( failures: [new \RuntimeException('Deferred listener failed.')], ); $exitCode = (new ConsoleRuntime($kernel))->run( new ArrayInput(['command' => 'list', '--raw' => true]), new BufferedOutput(), ); self::assertSame(0, $exitCode); } private function kernel(ContainerInterface $container): ConsoleKernel { return new ConsoleKernel( $container, new TenantContext($this->createStub(TenantService::class)), new IdentityContext(), ); } } final class ConsoleKernel implements KernelInterface { public int $terminations = 0; public ?ExecutionOutcome $outcome = null; public TerminationReport $terminationReport; public function __construct( private readonly ContainerInterface $services, private readonly TenantContext $tenantContext, private readonly IdentityContext $identityContext, ) { $this->terminationReport = new TerminationReport(); } public function boot(): void { } public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope { return new ExecutionScope( $descriptor, ExecutionContext::fromDescriptor($descriptor), $this->tenantContext, $this->identityContext, ); } public function terminateExecution( ExecutionScope $scope, ExecutionOutcome $outcome, ): TerminationReport { $this->terminations++; $this->outcome = $outcome; $scope->dispose(); $scope->markTerminated(); return $this->terminationReport; } public function shutdown(): void { } public function container(): ContainerInterface { return $this->services; } public function environment(): string { return 'test'; } public function debug(): bool { return false; } } final class ConsoleContainer implements ContainerInterface { public function __construct( private readonly array $services, private readonly ?\Throwable $failure = null, ) { } public function get(string $id): mixed { if ($this->failure !== null) { throw $this->failure; } return $this->services[$id]; } public function has(string $id): bool { return isset($this->services[$id]); } }