From 4f90f574752e457e0805b9e85a95d313a1ef2101 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:06:00 +0400 Subject: [PATCH 01/21] [#537] Characterize web runtime behavior before the refactor Add WebAppAdapter tests, driven only through start(), that record how the web flow behaves today so the pipeline refactor can be proven not to change it: OPTIONS preflight, JSON and HTML 404 responses, the matched route response, a view cache hit, and a dispatch exception that leaves the request uncleaned. Co-Authored-By: Claude Sonnet 5.5 --- tests/Unit/App/Adapters/WebAppAdapterTest.php | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/tests/Unit/App/Adapters/WebAppAdapterTest.php b/tests/Unit/App/Adapters/WebAppAdapterTest.php index c651d122..096fb281 100644 --- a/tests/Unit/App/Adapters/WebAppAdapterTest.php +++ b/tests/Unit/App/Adapters/WebAppAdapterTest.php @@ -2,9 +2,14 @@ namespace Quantum\Tests\Unit\App\Adapters; +use Quantum\Router\Exceptions\RouteException; use Quantum\Http\Exceptions\HttpException; use Quantum\App\Adapters\WebAppAdapter; +use Quantum\ResourceCache\ViewCache; use Quantum\Tests\Unit\AppTestCase; +use Quantum\Http\Enums\ContentType; +use Quantum\Http\Enums\StatusCode; +use Quantum\Router\MatchedRoute; use Quantum\Debugger\Debugger; use Quantum\Router\Route; use Quantum\Di\Di; @@ -70,6 +75,126 @@ public function testWebAppAdapterHandlesPageNotFoundGracefully(): void $this->assertNull(request()->getUri()); } + public function testWebAppAdapterStartHandlesOptionsPreflight(): void + { + request()->create('OPTIONS', '/test/am/tests'); + + ob_start(); + $result = $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertSame(0, $result); + $this->assertSame(StatusCode::NO_CONTENT, response()->getStatusCode()); + $this->assertSame('', response()->getContent()); + $this->assertSame('*', response()->getHeader('Access-Control-Allow-Origin')); + $this->assertSame('*', response()->getHeader('Access-Control-Allow-Methods')); + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + } + + public function testWebAppAdapterStartRespondsWithJsonNotFoundForJsonRequests(): void + { + request()->create('GET', '/non-existing-uri', [], ['Accept' => 'application/json']); + + ob_start(); + $result = $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertSame(0, $result); + $this->assertSame(StatusCode::NOT_FOUND, response()->getStatusCode()); + $this->assertSame(ContentType::JSON, response()->getContentType()); + $this->assertStringContainsString('Page not found', response()->getContent()); + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + } + + public function testWebAppAdapterStartRespondsWithHtmlNotFoundForOtherRequests(): void + { + request()->create('GET', '/non-existing-uri'); + + ob_start(); + $result = $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertSame(0, $result); + $this->assertSame(StatusCode::NOT_FOUND, response()->getStatusCode()); + $this->assertSame(ContentType::HTML, response()->getContentType()); + $this->assertNotSame('', response()->getContent()); + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + } + + public function testWebAppAdapterStartSendsMatchedRouteResponse(): void + { + request()->create('GET', '/test/am/tests'); + + ob_start(); + $result = $this->webAppAdapter->start(); + $output = ob_get_clean(); + + $this->assertSame(0, $result); + $this->assertSame(StatusCode::OK, response()->getStatusCode()); + $this->assertSame('', response()->getContent()); + $this->assertSame('', $output); + $this->assertSame('*', response()->getHeader('Access-Control-Allow-Origin')); + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + } + + public function testWebAppAdapterStartServesViewCacheHit(): void + { + request()->create('GET', '/test/am/tests'); + + $uri = request()->getUri(); + + // The view cache directory depends on the current module, which is only known once a route is matched. + request()->setMatchedRoute(new MatchedRoute( + (new Route(['GET'], '/test/am/tests', 'TestController', 'tests'))->module('Test'), + [] + )); + + if (!Di::isRegistered(ViewCache::class)) { + Di::register(ViewCache::class); + } + + $viewCache = Di::get(ViewCache::class); + $viewCache->setup(); + $viewCache->enableCaching(true); + $viewCache->set($uri, '

cached page

'); + + try { + ob_start(); + $result = $this->webAppAdapter->start(); + $output = ob_get_clean(); + + $this->assertSame(0, $result); + $this->assertSame('

cached page

', response()->getContent()); + $this->assertSame('

cached page

', $output); + } finally { + $viewCache->delete($uri); + $viewCache->enableCaching(false); + } + } + + public function testWebAppAdapterStartPropagatesDispatchExceptionWithoutCleanup(): void + { + request()->create('GET', '/test/Test/5'); + + ob_start(); + + try { + $this->webAppAdapter->start(); + $this->fail('Expected the dispatcher to fail for a missing controller action.'); + } catch (RouteException $exception) { + $this->assertInstanceOf(RouteException::class, $exception); + } finally { + ob_end_clean(); + } + + $this->assertNotNull(request()->getMatchedRoute()); + $this->assertNotNull(request()->getUri()); + } + public function testWebAppAdapterCleansUpOnException(): void { request()->create('GET', '/test/am/tests'); From ec924c4bffd6d9dcb1e8df4b5d2a7f0568c1f780 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:11:22 +0400 Subject: [PATCH 02/21] [#537] Characterize console runtime behavior before the refactor Add ConsoleAppAdapter tests that record how the console flow behaves today so the pipeline refactor can be proven not to change it: a missing command name fails, core and app commands are registered and really executed through a non-exiting Symfony Application, and the core:env command boots with the reduced stages while regular commands boot the environment, error handler and app config. Co-Authored-By: Claude Sonnet 5.5 --- .../App/Adapters/ConsoleAppAdapterTest.php | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/tests/Unit/App/Adapters/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/ConsoleAppAdapterTest.php index 7107e1b2..ce567091 100644 --- a/tests/Unit/App/Adapters/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/ConsoleAppAdapterTest.php @@ -4,7 +4,10 @@ use Quantum\App\Adapters\ConsoleAppAdapter; use Symfony\Component\Console\Application; +use Quantum\Environment\Environment; use Quantum\Tests\Unit\AppTestCase; +use Quantum\Tracer\ErrorHandler; +use Quantum\Di\Di; use Exception; use Mockery; @@ -77,4 +80,144 @@ public function testConsoleAppAdapterStartFails(): void $this->consoleAppAdapter->start(); } + + public function testConsoleAppAdapterStartFailsWithoutCommandName(): void + { + $_SERVER['argv'] = ['qt']; + + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Command `` is not defined'); + + $this->consoleAppAdapter->start(); + } + + public function testConsoleAppAdapterStartRegistersCoreCommands(): void + { + $directory = PROJECT_ROOT . DS . 'vendor' . DS . 'quantum' . DS . 'framework' . DS . 'src' . DS . 'Console' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Quantum\Console\Commands', function (string $commandName): void { + $this->assertSame(0, $this->startWithRealApplication($commandName)); + $this->assertSame($commandName, $GLOBALS['tmpCommandRan'] ?? null); + }); + } + + public function testConsoleAppAdapterStartRegistersAppCommands(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->assertSame(0, $this->startWithRealApplication($commandName)); + $this->assertSame($commandName, $GLOBALS['tmpCommandRan'] ?? null); + }); + } + + public function testConsoleAppAdapterBootsAllStagesForRegularCommands(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->assertTrue(Di::has(Environment::class)); + $this->assertTrue(Di::isRegistered(ErrorHandler::class)); + $this->assertTrue(config()->has('app')); + } + + public function testConsoleAppAdapterBootsReducedStagesForCoreEnvCommand(): void + { + $_SERVER['argv'] = ['qt', 'core:env']; + + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->assertFalse(Di::has(Environment::class)); + $this->assertFalse(Di::isRegistered(ErrorHandler::class)); + $this->assertFalse(config()->has('app')); + } + + /** + * Starts the adapter with a real Symfony Application (no auto exit, no exception catching), + * so the registered command is really executed and its exit code comes from Symfony. + */ + private function startWithRealApplication(string $commandName): ?int + { + $application = new Application('Qt Console Application', 'test'); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + + $adapter = Mockery::mock(ConsoleAppAdapter::class) + ->shouldAllowMockingProtectedMethods() + ->makePartial(); + + $adapter->shouldReceive('createApplication')->andReturn($application); + + $_SERVER['argv'] = ['qt', $commandName, '--quiet']; + + $adapter->__construct($this->createContext()); + + return $adapter->start(); + } + + /** + * Creates a temporary command class in the given directory, loads it, runs the test + * with the command name, and removes the file and any directories it had to create. + * The command records that it ran in $GLOBALS['tmpCommandRan']. + */ + private function withTemporaryCommand(string $directory, string $namespace, callable $test): void + { + $suffix = substr(md5(uniqid('', true)), 0, 8); + $className = 'TmpCommand' . $suffix; + $commandName = 'tmp:command' . $suffix; + $file = $directory . DS . $className . '.php'; + + $missing = []; + + for ($dir = $directory; !is_dir($dir); $dir = dirname($dir)) { + array_unshift($missing, $dir); + } + + $command = <<assertTrue(mkdir($directory, 0777, true)); + } + + $this->assertNotFalse(file_put_contents($file, $command)); + + require_once $file; + + $test($commandName); + } finally { + if (is_file($file)) { + unlink($file); + } + + foreach (array_reverse($missing) as $dir) { + if (is_dir($dir)) { + rmdir($dir); + } + } + + unset($GLOBALS['tmpCommandRan']); + } + } } From 6ee8f0e3c94d54d9ff166de11f0d988c702c2557 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:22:28 +0400 Subject: [PATCH 03/21] [#537] Add request pipeline skeleton Introduce RequestStageInterface, RequestContext and RequestPipeline as the foundation for running the web request as stages. RequestContext carries the matched route, response, view cache and a stopped flag for one request, while AppContext stays unchanged. The pipeline runs stages in order, rejects anything that is not a request stage, and leaves skipping to the stages. Nothing uses these classes yet. Co-Authored-By: Claude Sonnet 5.5 --- src/App/Contracts/RequestStageInterface.php | 25 ++++ src/App/RequestContext.php | 90 ++++++++++++ src/App/RequestPipeline.php | 54 ++++++++ tests/Unit/App/RequestContextTest.php | 79 +++++++++++ tests/Unit/App/RequestPipelineTest.php | 145 ++++++++++++++++++++ 5 files changed, 393 insertions(+) create mode 100644 src/App/Contracts/RequestStageInterface.php create mode 100644 src/App/RequestContext.php create mode 100644 src/App/RequestPipeline.php create mode 100644 tests/Unit/App/RequestContextTest.php create mode 100644 tests/Unit/App/RequestPipelineTest.php diff --git a/src/App/Contracts/RequestStageInterface.php b/src/App/Contracts/RequestStageInterface.php new file mode 100644 index 00000000..1506b22e --- /dev/null +++ b/src/App/Contracts/RequestStageInterface.php @@ -0,0 +1,25 @@ +appContext = $appContext; + } + + public function getAppContext(): AppContext + { + return $this->appContext; + } + + public function getMatchedRoute(): ?MatchedRoute + { + return $this->matchedRoute; + } + + public function setMatchedRoute(?MatchedRoute $matchedRoute): void + { + $this->matchedRoute = $matchedRoute; + } + + public function getResponse(): ?Response + { + return $this->response; + } + + public function setResponse(Response $response): void + { + $this->response = $response; + } + + public function getViewCache(): ?ViewCache + { + return $this->viewCache; + } + + public function setViewCache(ViewCache $viewCache): void + { + $this->viewCache = $viewCache; + } + + /** + * Marks the request as finished early, so stages that compute a response skip their work. + * The stage that sends the response still runs. + */ + public function stop(): void + { + $this->stopped = true; + } + + public function isStopped(): bool + { + return $this->stopped; + } +} diff --git a/src/App/RequestPipeline.php b/src/App/RequestPipeline.php new file mode 100644 index 00000000..389df277 --- /dev/null +++ b/src/App/RequestPipeline.php @@ -0,0 +1,54 @@ +stages = $stages; + } + + public function run(RequestContext $context): void + { + foreach ($this->stages as $stage) { + $stage->process($context); + } + } +} diff --git a/tests/Unit/App/RequestContextTest.php b/tests/Unit/App/RequestContextTest.php new file mode 100644 index 00000000..3fc3e8da --- /dev/null +++ b/tests/Unit/App/RequestContextTest.php @@ -0,0 +1,79 @@ +appContext = App::getContext(); + $this->requestContext = new RequestContext($this->appContext); + } + + public function testRequestContextExposesAppContext(): void + { + $this->assertSame($this->appContext, $this->requestContext->getAppContext()); + } + + public function testRequestContextStoresMatchedRoute(): void + { + $this->assertNull($this->requestContext->getMatchedRoute()); + + $matchedRoute = new MatchedRoute(new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), []); + + $this->requestContext->setMatchedRoute($matchedRoute); + + $this->assertSame($matchedRoute, $this->requestContext->getMatchedRoute()); + } + + public function testRequestContextStoresResponse(): void + { + $this->assertNull($this->requestContext->getResponse()); + + $this->requestContext->setResponse(response()); + + $this->assertSame(response(), $this->requestContext->getResponse()); + } + + public function testRequestContextStoresViewCache(): void + { + $this->assertNull($this->requestContext->getViewCache()); + + if (!Di::isRegistered(ViewCache::class)) { + Di::register(ViewCache::class); + } + + $viewCache = Di::get(ViewCache::class); + + $this->requestContext->setViewCache($viewCache); + + $this->assertSame($viewCache, $this->requestContext->getViewCache()); + } + + public function testRequestContextIsNotStoppedByDefault(): void + { + $this->assertFalse($this->requestContext->isStopped()); + } + + public function testRequestContextStopMarksContextStopped(): void + { + $this->requestContext->stop(); + + $this->assertTrue($this->requestContext->isStopped()); + } +} diff --git a/tests/Unit/App/RequestPipelineTest.php b/tests/Unit/App/RequestPipelineTest.php new file mode 100644 index 00000000..a6c54760 --- /dev/null +++ b/tests/Unit/App/RequestPipelineTest.php @@ -0,0 +1,145 @@ +requestContext = new RequestContext($this->createContext()); + } + + public function tearDown(): void + { + $this->clearAppContext(); + } + + public function testPipelineRunsStagesInOrder(): void + { + $log = []; + + $pipeline = new RequestPipeline([ + $this->createStage(function () use (&$log): void { + $log[] = 'first'; + }), + $this->createStage(function () use (&$log): void { + $log[] = 'second'; + }), + $this->createStage(function () use (&$log): void { + $log[] = 'third'; + }), + ]); + + $pipeline->run($this->requestContext); + + $this->assertSame(['first', 'second', 'third'], $log); + } + + public function testEmptyPipelineRunsWithoutError(): void + { + $pipeline = new RequestPipeline([]); + + $pipeline->run($this->requestContext); + + $this->assertFalse($this->requestContext->isStopped()); + } + + public function testPipelinePassesContextToStages(): void + { + $received = null; + + $pipeline = new RequestPipeline([ + $this->createStage(function (RequestContext $context) use (&$received): void { + $received = $context; + }), + ]); + + $pipeline->run($this->requestContext); + + $this->assertSame($this->requestContext, $received); + } + + public function testPipelinePropagatesException(): void + { + $pipeline = new RequestPipeline([ + $this->createStage(function (): void { + throw new RuntimeException('Stage failed'); + }), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stage failed'); + + $pipeline->run($this->requestContext); + } + + public function testPipelineKeepsRunningStagesAfterContextIsStopped(): void + { + $log = []; + + $pipeline = new RequestPipeline([ + $this->createStage(function (RequestContext $context) use (&$log): void { + $log[] = 'first'; + $context->stop(); + }), + $this->createStage(function () use (&$log): void { + $log[] = 'second'; + }), + ]); + + $pipeline->run($this->requestContext); + + $this->assertSame(['first', 'second'], $log); + $this->assertTrue($this->requestContext->isStopped()); + } + + public function testPipelineRejectsInvalidStage(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All stages must implement'); + + new RequestPipeline([new \stdClass()]); + } + + public function testPipelineRejectsBootStage(): void + { + $bootStage = new class () implements BootStageInterface { + public function process(AppContext $context): void + { + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All stages must implement'); + + new RequestPipeline([$bootStage]); + } + + private function createStage(callable $callback): RequestStageInterface + { + return new class ($callback) implements RequestStageInterface { + private $callback; + + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function process(RequestContext $context): void + { + ($this->callback)($context); + } + }; + } +} From 9e62e979c8b6ec8a773cc59aa49e71a20aecea88 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:40:40 +0400 Subject: [PATCH 04/21] [#537] Add preflight and send request stages Add HandlePreflightStage, which answers an OPTIONS request with an empty 204 response and stops the remaining stages, and SendResponseStage, which applies the CORS headers, sends the response, and always clears the request state afterwards, even when sending fails. The stages are not wired into WebAppAdapter yet. Co-Authored-By: Claude Sonnet 5.5 --- .../Stages/Request/HandlePreflightStage.php | 35 ++++++ src/App/Stages/Request/SendResponseStage.php | 74 +++++++++++ .../Request/HandlePreflightStageTest.php | 42 +++++++ .../Stages/Request/SendResponseStageTest.php | 118 ++++++++++++++++++ 4 files changed, 269 insertions(+) create mode 100644 src/App/Stages/Request/HandlePreflightStage.php create mode 100644 src/App/Stages/Request/SendResponseStage.php create mode 100644 tests/Unit/App/Stages/Request/HandlePreflightStageTest.php create mode 100644 tests/Unit/App/Stages/Request/SendResponseStageTest.php diff --git a/src/App/Stages/Request/HandlePreflightStage.php b/src/App/Stages/Request/HandlePreflightStage.php new file mode 100644 index 00000000..8f493e95 --- /dev/null +++ b/src/App/Stages/Request/HandlePreflightStage.php @@ -0,0 +1,35 @@ +isMethod('OPTIONS')) { + return; + } + + $context->setResponse(response()->setStatusCode(StatusCode::NO_CONTENT)); + $context->stop(); + } +} diff --git a/src/App/Stages/Request/SendResponseStage.php b/src/App/Stages/Request/SendResponseStage.php new file mode 100644 index 00000000..1a62b2a4 --- /dev/null +++ b/src/App/Stages/Request/SendResponseStage.php @@ -0,0 +1,74 @@ +getResponse(); + + if ($response === null) { + throw new RuntimeException('The request has no response to send.'); + } + + $this->handleCors($response); + $response->send(); + } finally { + $this->cleanupRequestContext(); + } + } + + /** + * @throws ConfigException|DiException|ReflectionException + */ + private function handleCors(Response $response): void + { + if (!config()->has('cors')) { + config()->import(new Setup('config', 'cors')); + } + + foreach (config()->get('cors') as $key => $value) { + $response->setHeader($key, (string) $value); + } + } + + /** + * Clears request-scoped state after the response has been sent. + */ + private function cleanupRequestContext(): void + { + request()->setMatchedRoute(null); + request()->flush(); + } +} diff --git a/tests/Unit/App/Stages/Request/HandlePreflightStageTest.php b/tests/Unit/App/Stages/Request/HandlePreflightStageTest.php new file mode 100644 index 00000000..70fd4e69 --- /dev/null +++ b/tests/Unit/App/Stages/Request/HandlePreflightStageTest.php @@ -0,0 +1,42 @@ +requestContext = new RequestContext(App::getContext()); + } + + public function testHandlePreflightStageRespondsNoContentToOptionsRequest(): void + { + request()->create('OPTIONS', '/test/am/tests'); + + (new HandlePreflightStage())->process($this->requestContext); + + $this->assertSame(response(), $this->requestContext->getResponse()); + $this->assertSame(StatusCode::NO_CONTENT, $this->requestContext->getResponse()->getStatusCode()); + $this->assertTrue($this->requestContext->isStopped()); + } + + public function testHandlePreflightStageIgnoresOtherMethods(): void + { + request()->create('GET', '/test/am/tests'); + + (new HandlePreflightStage())->process($this->requestContext); + + $this->assertNull($this->requestContext->getResponse()); + $this->assertFalse($this->requestContext->isStopped()); + } +} diff --git a/tests/Unit/App/Stages/Request/SendResponseStageTest.php b/tests/Unit/App/Stages/Request/SendResponseStageTest.php new file mode 100644 index 00000000..8f70d559 --- /dev/null +++ b/tests/Unit/App/Stages/Request/SendResponseStageTest.php @@ -0,0 +1,118 @@ +requestContext = new RequestContext(App::getContext()); + } + + public function testSendResponseStageSendsTheResponse(): void + { + $this->requestContext->setResponse(response()->html('

hello

')); + + ob_start(); + (new SendResponseStage())->process($this->requestContext); + $output = ob_get_clean(); + + $this->assertSame('

hello

', $output); + } + + public function testSendResponseStageAppliesCorsHeaders(): void + { + $this->assertFalse(config()->has('cors')); + + $this->requestContext->setResponse(response()->html('')); + + ob_start(); + (new SendResponseStage())->process($this->requestContext); + ob_end_clean(); + + $this->assertTrue(config()->has('cors')); + $this->assertSame('*', response()->getHeader('Access-Control-Allow-Origin')); + $this->assertSame('*', response()->getHeader('Access-Control-Allow-Methods')); + } + + public function testSendResponseStageCleansUpRequestAfterSending(): void + { + $this->prepareMatchedRequest(); + + $this->requestContext->setResponse(response()->html('')); + + ob_start(); + (new SendResponseStage())->process($this->requestContext); + ob_end_clean(); + + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + } + + public function testSendResponseStageCleansUpRequestWhenSendFails(): void + { + $this->prepareMatchedRequest(); + + response()->setHeader('X-Test', '1'); + response()->json(['foo' => 'bar']); + + $this->requestContext->setResponse(new class () extends Response { + public function send(): void + { + throw new HttpException('boom'); + } + }); + + try { + (new SendResponseStage())->process($this->requestContext); + $this->fail('Expected response sending to fail.'); + } catch (HttpException $exception) { + $this->assertSame('boom', $exception->getMessage()); + } + + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + $this->assertSame(['foo' => 'bar'], response()->all()); + $this->assertSame('1', response()->getHeader('X-Test')); + $this->assertSame(200, response()->getStatusCode()); + } + + public function testSendResponseStageFailsAndCleansUpWithoutResponse(): void + { + $this->prepareMatchedRequest(); + + try { + (new SendResponseStage())->process($this->requestContext); + $this->fail('Expected the stage to fail without a response.'); + } catch (RuntimeException $exception) { + $this->assertSame('The request has no response to send.', $exception->getMessage()); + } + + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + } + + private function prepareMatchedRequest(): void + { + request()->create('GET', '/test/am/tests'); + + request()->setMatchedRoute(new MatchedRoute( + new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), + [] + )); + } +} From 5fbc38bae0379c42dde38f9afb698fb7c666933b Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:57:40 +0400 Subject: [PATCH 05/21] [#537] Add route resolution request stages Add ResolveRouteStage, which finds the route for the current request and stores it on the request context and the request, and HandleRouteNotFoundStage, which answers a request with no matched route with the page not found response and stops the remaining stages. Both skip when the context is already stopped. The stages are not wired into WebAppAdapter yet. Co-Authored-By: Claude Sonnet 5.5 --- .../Request/HandleRouteNotFoundStage.php | 39 ++++++++++ src/App/Stages/Request/ResolveRouteStage.php | 46 ++++++++++++ .../Request/HandleRouteNotFoundStageTest.php | 71 +++++++++++++++++++ .../Stages/Request/ResolveRouteStageTest.php | 55 ++++++++++++++ 4 files changed, 211 insertions(+) create mode 100644 src/App/Stages/Request/HandleRouteNotFoundStage.php create mode 100644 src/App/Stages/Request/ResolveRouteStage.php create mode 100644 tests/Unit/App/Stages/Request/HandleRouteNotFoundStageTest.php create mode 100644 tests/Unit/App/Stages/Request/ResolveRouteStageTest.php diff --git a/src/App/Stages/Request/HandleRouteNotFoundStage.php b/src/App/Stages/Request/HandleRouteNotFoundStage.php new file mode 100644 index 00000000..bd9b5b23 --- /dev/null +++ b/src/App/Stages/Request/HandleRouteNotFoundStage.php @@ -0,0 +1,39 @@ +isStopped() || $context->getMatchedRoute() !== null) { + return; + } + + $context->setResponse(page_not_found_response()); + $context->stop(); + } +} diff --git a/src/App/Stages/Request/ResolveRouteStage.php b/src/App/Stages/Request/ResolveRouteStage.php new file mode 100644 index 00000000..020fadba --- /dev/null +++ b/src/App/Stages/Request/ResolveRouteStage.php @@ -0,0 +1,46 @@ +isStopped()) { + return; + } + + $matchedRoute = (new RouteFinder(Di::get(RouteCollection::class)))->find(request()); + + if ($matchedRoute === null) { + return; + } + + request()->setMatchedRoute($matchedRoute); + $context->setMatchedRoute($matchedRoute); + } +} diff --git a/tests/Unit/App/Stages/Request/HandleRouteNotFoundStageTest.php b/tests/Unit/App/Stages/Request/HandleRouteNotFoundStageTest.php new file mode 100644 index 00000000..2811b306 --- /dev/null +++ b/tests/Unit/App/Stages/Request/HandleRouteNotFoundStageTest.php @@ -0,0 +1,71 @@ +requestContext = new RequestContext(App::getContext()); + } + + public function testHandleRouteNotFoundStageRespondsNotFoundWhenNoRouteMatched(): void + { + request()->create('GET', '/non-existing-uri'); + + (new HandleRouteNotFoundStage())->process($this->requestContext); + + $this->assertSame(response(), $this->requestContext->getResponse()); + $this->assertSame(StatusCode::NOT_FOUND, $this->requestContext->getResponse()->getStatusCode()); + $this->assertSame(ContentType::HTML, $this->requestContext->getResponse()->getContentType()); + $this->assertTrue($this->requestContext->isStopped()); + } + + public function testHandleRouteNotFoundStageRespondsWithJsonForJsonRequests(): void + { + request()->create('GET', '/non-existing-uri', [], ['Accept' => 'application/json']); + + (new HandleRouteNotFoundStage())->process($this->requestContext); + + $this->assertSame(StatusCode::NOT_FOUND, $this->requestContext->getResponse()->getStatusCode()); + $this->assertSame(ContentType::JSON, $this->requestContext->getResponse()->getContentType()); + $this->assertStringContainsString('Page not found', $this->requestContext->getResponse()->getContent()); + $this->assertTrue($this->requestContext->isStopped()); + } + + public function testHandleRouteNotFoundStageIgnoresMatchedRoute(): void + { + $this->requestContext->setMatchedRoute( + new MatchedRoute(new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), []) + ); + + (new HandleRouteNotFoundStage())->process($this->requestContext); + + $this->assertNull($this->requestContext->getResponse()); + $this->assertFalse($this->requestContext->isStopped()); + } + + public function testHandleRouteNotFoundStageSkipsWhenContextIsStopped(): void + { + request()->create('GET', '/non-existing-uri'); + + $this->requestContext->stop(); + + (new HandleRouteNotFoundStage())->process($this->requestContext); + + $this->assertNull($this->requestContext->getResponse()); + } +} diff --git a/tests/Unit/App/Stages/Request/ResolveRouteStageTest.php b/tests/Unit/App/Stages/Request/ResolveRouteStageTest.php new file mode 100644 index 00000000..0bbc02ec --- /dev/null +++ b/tests/Unit/App/Stages/Request/ResolveRouteStageTest.php @@ -0,0 +1,55 @@ +requestContext = new RequestContext(App::getContext()); + } + + public function testResolveRouteStageStoresMatchedRoute(): void + { + request()->create('GET', '/test/am/tests'); + + (new ResolveRouteStage())->process($this->requestContext); + + $this->assertNotNull($this->requestContext->getMatchedRoute()); + $this->assertSame('tests', $this->requestContext->getMatchedRoute()->getRoute()->getAction()); + $this->assertSame($this->requestContext->getMatchedRoute(), request()->getMatchedRoute()); + $this->assertFalse($this->requestContext->isStopped()); + } + + public function testResolveRouteStageLeavesContextEmptyWhenNoRouteMatches(): void + { + request()->create('GET', '/non-existing-uri'); + + (new ResolveRouteStage())->process($this->requestContext); + + $this->assertNull($this->requestContext->getMatchedRoute()); + $this->assertNull(request()->getMatchedRoute()); + $this->assertFalse($this->requestContext->isStopped()); + } + + public function testResolveRouteStageSkipsWhenContextIsStopped(): void + { + request()->create('GET', '/test/am/tests'); + + $this->requestContext->stop(); + + (new ResolveRouteStage())->process($this->requestContext); + + $this->assertNull($this->requestContext->getMatchedRoute()); + $this->assertNull(request()->getMatchedRoute()); + } +} From a87ee63063d140edd6785ff87d0dd6cc9af69d05 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:20:35 +0400 Subject: [PATCH 06/21] [#537] Clean up view cache folders in the web characterization test The view cache hit test creates cache/views and cache/views/test through ViewCache::setup() and used to leave them behind. Record whether the folders existed before the test and remove only the ones it created. Co-Authored-By: Claude Sonnet 5.5 --- tests/Unit/App/Adapters/WebAppAdapterTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/Unit/App/Adapters/WebAppAdapterTest.php b/tests/Unit/App/Adapters/WebAppAdapterTest.php index 096fb281..fbd40ad6 100644 --- a/tests/Unit/App/Adapters/WebAppAdapterTest.php +++ b/tests/Unit/App/Adapters/WebAppAdapterTest.php @@ -147,6 +147,11 @@ public function testWebAppAdapterStartServesViewCacheHit(): void $uri = request()->getUri(); + $viewsDir = base_dir() . DS . 'cache' . DS . 'views'; + $moduleDir = $viewsDir . DS . 'test'; + $viewsDirExisted = is_dir($viewsDir); + $moduleDirExisted = is_dir($moduleDir); + // The view cache directory depends on the current module, which is only known once a route is matched. request()->setMatchedRoute(new MatchedRoute( (new Route(['GET'], '/test/am/tests', 'TestController', 'tests'))->module('Test'), @@ -173,6 +178,14 @@ public function testWebAppAdapterStartServesViewCacheHit(): void } finally { $viewCache->delete($uri); $viewCache->enableCaching(false); + + if (!$moduleDirExisted && is_dir($moduleDir)) { + rmdir($moduleDir); + } + + if (!$viewsDirExisted && is_dir($viewsDir)) { + rmdir($viewsDir); + } } } From be3c104a430714968a323c4c720e35c0d59d7fba Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:20:36 +0400 Subject: [PATCH 07/21] [#537] Add prepare request stage Add PrepareRequestStage, which loads the language, logs the registered event listeners to the debugbar when debug is on, and sets up the view cache and stores it on the request context, in the same order as the former WebAppAdapter flow. It skips when the context is stopped, so an OPTIONS or not found request does none of this work. The stage is not wired into WebAppAdapter yet. Co-Authored-By: Claude Sonnet 5.5 --- .../Stages/Request/PrepareRequestStage.php | 79 ++++++++++ .../Request/PrepareRequestStageTest.php | 135 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 src/App/Stages/Request/PrepareRequestStage.php create mode 100644 tests/Unit/App/Stages/Request/PrepareRequestStageTest.php diff --git a/src/App/Stages/Request/PrepareRequestStage.php b/src/App/Stages/Request/PrepareRequestStage.php new file mode 100644 index 00000000..66586459 --- /dev/null +++ b/src/App/Stages/Request/PrepareRequestStage.php @@ -0,0 +1,79 @@ +isStopped()) { + return; + } + + LangFactory::get(); + + $this->logDebugInfo(); + + $context->setViewCache($this->setupViewCache()); + } + + /** + * @throws DiException|ReflectionException + */ + private function logDebugInfo(): void + { + $debugBar = debugbar(); + + if ($debugBar->isEnabled()) { + $debugBar->addToStoreCell(Debugger::EVENTS, 'info', event()->getRegistered()); + } + } + + /** + * @throws ConfigException|DiException|ReflectionException + */ + private function setupViewCache(): ViewCache + { + if (!Di::isRegistered(ViewCache::class)) { + Di::register(ViewCache::class); + } + + $viewCache = Di::get(ViewCache::class); + + if ($viewCache->isEnabled()) { + $viewCache->setup(); + } + + return $viewCache; + } +} diff --git a/tests/Unit/App/Stages/Request/PrepareRequestStageTest.php b/tests/Unit/App/Stages/Request/PrepareRequestStageTest.php new file mode 100644 index 00000000..f33190f9 --- /dev/null +++ b/tests/Unit/App/Stages/Request/PrepareRequestStageTest.php @@ -0,0 +1,135 @@ +requestContext = new RequestContext(App::getContext()); + + request()->create('GET', '/test/am/tests'); + + request()->setMatchedRoute(new MatchedRoute( + (new Route(['GET'], '/test/am/tests', 'TestController', 'tests'))->module('Test'), + [] + )); + } + + public function testPrepareRequestStageLoadsLanguage(): void + { + $this->assertFalse(config()->has('lang')); + + (new PrepareRequestStage())->process($this->requestContext); + + $this->assertTrue(config()->has('lang')); + } + + public function testPrepareRequestStageLogsRegisteredEventsToDebugger(): void + { + config()->set('app.debug', true); + + $listener = function (): void { + }; + + event()->listen('test.event', $listener); + + (new PrepareRequestStage())->process($this->requestContext); + + $cell = debugbar()->getStoreCell(Debugger::EVENTS); + + $this->assertCount(1, $cell); + $this->assertSame(['test.event' => [$listener]], $cell[0]['info']); + } + + public function testPrepareRequestStageLogsNothingToDebuggerWithoutEventListeners(): void + { + config()->set('app.debug', true); + + $this->assertSame([], event()->getRegistered()); + + (new PrepareRequestStage())->process($this->requestContext); + + $this->assertSame([], debugbar()->getStoreCell(Debugger::EVENTS)); + } + + public function testPrepareRequestStageSkipsDebuggerLoggingWhenDebugDisabled(): void + { + config()->set('app.debug', false); + + event()->listen('test.event', function (): void { + }); + + (new PrepareRequestStage())->process($this->requestContext); + + $this->assertSame([], debugbar()->getStoreCell(Debugger::EVENTS)); + } + + public function testPrepareRequestStageStoresViewCacheOnContext(): void + { + $this->assertNull($this->requestContext->getViewCache()); + + (new PrepareRequestStage())->process($this->requestContext); + + $this->assertInstanceOf(ViewCache::class, $this->requestContext->getViewCache()); + $this->assertSame(Di::get(ViewCache::class), $this->requestContext->getViewCache()); + } + + public function testPrepareRequestStageSetsUpViewCacheWhenEnabled(): void + { + $viewsDir = base_dir() . DS . 'cache' . DS . 'views'; + $moduleDir = $viewsDir . DS . 'test'; + $viewsDirExisted = is_dir($viewsDir); + + $this->assertFalse(config()->has('view_cache')); + + Di::register(ViewCache::class); + Di::get(ViewCache::class)->enableCaching(true); + + try { + (new PrepareRequestStage())->process($this->requestContext); + + $this->assertTrue(config()->has('view_cache')); + $this->assertDirectoryExists($moduleDir); + } finally { + if (is_dir($moduleDir)) { + rmdir($moduleDir); + } + + if (!$viewsDirExisted && is_dir($viewsDir)) { + rmdir($viewsDir); + } + } + } + + public function testPrepareRequestStageDoesNotSetUpViewCacheWhenDisabled(): void + { + (new PrepareRequestStage())->process($this->requestContext); + + $this->assertFalse(config()->has('view_cache')); + } + + public function testPrepareRequestStageSkipsWhenContextIsStopped(): void + { + $this->requestContext->stop(); + + (new PrepareRequestStage())->process($this->requestContext); + + $this->assertFalse(config()->has('lang')); + $this->assertNull($this->requestContext->getViewCache()); + } +} From c1f0579f2da096108d85f1bc9774d6b1ccad31b4 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:26:18 +0400 Subject: [PATCH 08/21] [#537] Add dispatch request stage Add DispatchRequestStage, which produces the response of a matched request: the cached view if there is one, otherwise the dispatched route, wrapped by the module and framework middlewares, and stores it on the request context. It skips when the context is stopped and fails clearly when the matched route or the view cache was not set. The stage is not wired into WebAppAdapter yet. Co-Authored-By: Claude Sonnet 5.5 --- .../Stages/Request/DispatchRequestStage.php | 58 +++++ .../Request/DispatchRequestStageTest.php | 206 ++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 src/App/Stages/Request/DispatchRequestStage.php create mode 100644 tests/Unit/App/Stages/Request/DispatchRequestStageTest.php diff --git a/src/App/Stages/Request/DispatchRequestStage.php b/src/App/Stages/Request/DispatchRequestStage.php new file mode 100644 index 00000000..9120f8fa --- /dev/null +++ b/src/App/Stages/Request/DispatchRequestStage.php @@ -0,0 +1,58 @@ +isStopped()) { + return; + } + + $matchedRoute = $context->getMatchedRoute(); + $viewCache = $context->getViewCache(); + + if ($matchedRoute === null) { + throw new RuntimeException('Matched route is not set.'); + } + + if ($viewCache === null) { + throw new RuntimeException('View cache is not set.'); + } + + $terminal = fn (Request $request): Response => $viewCache->getCachedResponse($request->getUri() ?? '') + ?? (new RouteDispatcher())->dispatch($matchedRoute, $request); + + $context->setResponse((new MiddlewareManager($matchedRoute))->applyMiddlewares(request(), $terminal)); + } +} diff --git a/tests/Unit/App/Stages/Request/DispatchRequestStageTest.php b/tests/Unit/App/Stages/Request/DispatchRequestStageTest.php new file mode 100644 index 00000000..24449d83 --- /dev/null +++ b/tests/Unit/App/Stages/Request/DispatchRequestStageTest.php @@ -0,0 +1,206 @@ +json(['status' => 'blocked']); + } + } +} + +namespace Quantum\Tests\Unit\App\Stages\Request { + use Quantum\Tests\_root\modules\Test\Middlewares\DispatchStageMwPassThrough; + use Quantum\App\Stages\Request\DispatchRequestStage; + use Quantum\App\Stages\Request\ResolveRouteStage; + use Quantum\Router\Exceptions\RouteException; + use Quantum\Tests\Unit\AppTestCase; + use Quantum\ResourceCache\ViewCache; + use Quantum\App\RequestContext; + use Quantum\Router\MatchedRoute; + use Quantum\Http\Enums\StatusCode; + use Quantum\Router\Route; + use Quantum\App\App; + use RuntimeException; + use Quantum\Di\Di; + + class DispatchRequestStageTest extends AppTestCase + { + private RequestContext $requestContext; + + public function setUp(): void + { + parent::setUp(); + + $this->requestContext = new RequestContext(App::getContext()); + + DispatchStageMwPassThrough::$calls = []; + } + + public function testDispatchRequestStageDispatchesMatchedRoute(): void + { + $this->prepareRequest('/test/am/tests'); + + (new DispatchRequestStage())->process($this->requestContext); + + $this->assertSame(response(), $this->requestContext->getResponse()); + $this->assertSame(StatusCode::OK, $this->requestContext->getResponse()->getStatusCode()); + $this->assertSame('', $this->requestContext->getResponse()->getContent()); + } + + public function testDispatchRequestStageServesViewCacheHit(): void + { + $this->prepareRequest('/test/am/tests'); + + $uri = request()->getUri(); + $viewsDir = base_dir() . DS . 'cache' . DS . 'views'; + $moduleDir = $viewsDir . DS . 'test'; + $viewsDirExisted = is_dir($viewsDir); + $moduleDirExisted = is_dir($moduleDir); + + $viewCache = $this->requestContext->getViewCache(); + $viewCache->setup(); + $viewCache->enableCaching(true); + $viewCache->set($uri, '

cached page

'); + + try { + (new DispatchRequestStage())->process($this->requestContext); + + $this->assertSame('

cached page

', $this->requestContext->getResponse()->getContent()); + } finally { + $viewCache->delete($uri); + $viewCache->enableCaching(false); + + if (!$moduleDirExisted && is_dir($moduleDir)) { + rmdir($moduleDir); + } + + if (!$viewsDirExisted && is_dir($viewsDir)) { + rmdir($viewsDir); + } + } + } + + public function testDispatchRequestStageRunsModuleMiddlewaresBeforeController(): void + { + $this->prepareRequest('/test/am/tests'); + + $this->requestContext->getMatchedRoute()->getRoute()->addMiddlewares(['DispatchStageMwPassThrough']); + + (new DispatchRequestStage())->process($this->requestContext); + + $this->assertSame(['DispatchStageMwPassThrough'], DispatchStageMwPassThrough::$calls); + $this->assertSame(StatusCode::OK, $this->requestContext->getResponse()->getStatusCode()); + } + + public function testDispatchRequestStageLetsMiddlewareShortCircuit(): void + { + $this->prepareRequest('/test/am/tests'); + + // The controller does not exist, so reaching the dispatcher would throw. + $matchedRoute = new MatchedRoute($this->createRoute('tests', ['DispatchStageMwBlocker'], 'MissingController'), []); + + $this->requestContext->setMatchedRoute($matchedRoute); + request()->setMatchedRoute($matchedRoute); + + (new DispatchRequestStage())->process($this->requestContext); + + $this->assertStringContainsString('blocked', $this->requestContext->getResponse()->getContent()); + } + + public function testDispatchRequestStagePropagatesDispatchException(): void + { + $this->prepareRequest('/test/Test/5'); + + try { + (new DispatchRequestStage())->process($this->requestContext); + $this->fail('Expected the dispatcher to fail for a missing controller action.'); + } catch (RouteException $exception) { + $this->assertInstanceOf(RouteException::class, $exception); + } + + $this->assertNull($this->requestContext->getResponse()); + } + + public function testDispatchRequestStageFailsWithoutMatchedRoute(): void + { + $this->requestContext->setViewCache($this->viewCache()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Matched route is not set.'); + + (new DispatchRequestStage())->process($this->requestContext); + } + + public function testDispatchRequestStageFailsWithoutViewCache(): void + { + $this->requestContext->setMatchedRoute(new MatchedRoute($this->createRoute('tests'), [])); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('View cache is not set.'); + + (new DispatchRequestStage())->process($this->requestContext); + } + + public function testDispatchRequestStageSkipsWhenContextIsStopped(): void + { + $this->prepareRequest('/test/am/tests'); + + $this->requestContext->stop(); + + (new DispatchRequestStage())->process($this->requestContext); + + $this->assertNull($this->requestContext->getResponse()); + } + + private function prepareRequest(string $uri): void + { + request()->create('GET', $uri); + + (new ResolveRouteStage())->process($this->requestContext); + + $this->requestContext->setViewCache($this->viewCache()); + } + + private function viewCache(): ViewCache + { + if (!Di::isRegistered(ViewCache::class)) { + Di::register(ViewCache::class); + } + + return Di::get(ViewCache::class); + } + + /** + * @param string[] $middlewares + */ + private function createRoute(string $action, array $middlewares = [], string $controller = 'TestController'): Route + { + $route = (new Route(['GET'], '/test/am/tests', $controller, $action))->module('Test'); + + if ($middlewares !== []) { + $route->addMiddlewares($middlewares); + } + + return $route; + } + } +} From 9deb8e8e2b80b1c933c7fc4d62b695da2cabfd11 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:56:20 +0400 Subject: [PATCH 09/21] [#537] Run the web request through the request pipeline WebAppAdapter::start() now builds a RequestContext and runs a RequestPipeline of the preflight, route, not found, prepare, dispatch and send stages instead of the flat sequence of trait methods, and WebAppTrait is removed. Behavior is unchanged: the characterization tests written before the refactor pass without edits. Only the four tests that reached private trait methods by reflection are removed, as the stage tests now cover them. Two tests are added to pin that preflight and not found requests do not load the language. Co-Authored-By: Claude Sonnet 5.5 --- src/App/Adapters/WebAppAdapter.php | 61 +++------ .../Stages/Request/DispatchRequestStage.php | 2 +- src/App/Traits/WebAppTrait.php | 128 ------------------ tests/Unit/App/Adapters/WebAppAdapterTest.php | 100 ++++---------- 4 files changed, 44 insertions(+), 247 deletions(-) delete mode 100644 src/App/Traits/WebAppTrait.php diff --git a/src/App/Adapters/WebAppAdapter.php b/src/App/Adapters/WebAppAdapter.php index 90d533ad..025416ef 100644 --- a/src/App/Adapters/WebAppAdapter.php +++ b/src/App/Adapters/WebAppAdapter.php @@ -10,30 +10,24 @@ namespace Quantum\App\Adapters; -use Quantum\Middleware\Exceptions\MiddlewareException; -use Quantum\Config\Exceptions\ConfigException; +use Quantum\App\Stages\Request\HandleRouteNotFoundStage; +use Quantum\App\Stages\Request\HandlePreflightStage; +use Quantum\App\Stages\Request\DispatchRequestStage; +use Quantum\App\Stages\Request\PrepareRequestStage; +use Quantum\App\Stages\Request\SendResponseStage; +use Quantum\App\Stages\Request\ResolveRouteStage; use Quantum\App\Stages\SetupErrorHandlerStage; -use Quantum\Router\Exceptions\RouteException; use Quantum\App\Stages\LoadEnvironmentStage; -use Quantum\Csrf\Exceptions\CsrfException; -use Quantum\Lang\Exceptions\LangException; use Quantum\App\Stages\LoadAppConfigStage; -use Quantum\App\Exceptions\BaseException; use Quantum\App\Stages\InitDebuggerStage; -use Quantum\Middleware\MiddlewareManager; use Quantum\App\Stages\LoadModulesStage; use Quantum\App\Stages\LoadHelpersStage; -use Quantum\Di\Exceptions\DiException; use Quantum\App\Stages\InitHttpStage; -use Quantum\App\Traits\WebAppTrait; -use Quantum\Router\RouteDispatcher; -use Quantum\Http\Enums\StatusCode; +use Quantum\App\RequestPipeline; +use Quantum\App\RequestContext; use Quantum\App\Enums\ExitCode; use Quantum\App\BootPipeline; use Quantum\App\AppContext; -use Quantum\Http\Response; -use Quantum\Http\Request; -use ReflectionException; /** * Class WebAppAdapter @@ -41,8 +35,6 @@ */ class WebAppAdapter extends AppAdapter { - use WebAppTrait; - public function __construct(AppContext $context) { parent::__construct($context); @@ -60,37 +52,18 @@ public function __construct(AppContext $context) $pipeline->run($this->context); } - /** - * Starts the web app - * @throws MiddlewareException|LangException|RouteException|CsrfException|ConfigException|DiException|BaseException|ReflectionException - */ public function start(): ?int { - if (request()->isMethod('OPTIONS')) { - $this->sendResponse(response()->setStatusCode(StatusCode::NO_CONTENT)); - return ExitCode::SUCCESS; - } - - $matchedRoute = $this->resolveRoute(); - - if ($matchedRoute === null) { - $this->sendResponse(page_not_found_response()); - - return ExitCode::SUCCESS; - } - - $this->loadLanguage(); - - $this->logDebugInfo(); - - $viewCache = $this->setupViewCache(); - - $terminal = fn (Request $request): Response => $viewCache->getCachedResponse($request->getUri() ?? '') - ?? (new RouteDispatcher())->dispatch($matchedRoute, $request); - - $response = (new MiddlewareManager($matchedRoute))->applyMiddlewares(request(), $terminal); + $pipeline = new RequestPipeline([ + new HandlePreflightStage(), + new ResolveRouteStage(), + new HandleRouteNotFoundStage(), + new PrepareRequestStage(), + new DispatchRequestStage(), + new SendResponseStage(), + ]); - $this->sendResponse($response); + $pipeline->run(new RequestContext($this->context)); return ExitCode::SUCCESS; } diff --git a/src/App/Stages/Request/DispatchRequestStage.php b/src/App/Stages/Request/DispatchRequestStage.php index 9120f8fa..c2d758e8 100644 --- a/src/App/Stages/Request/DispatchRequestStage.php +++ b/src/App/Stages/Request/DispatchRequestStage.php @@ -10,9 +10,9 @@ namespace Quantum\App\Stages\Request; +use Quantum\Middleware\Exceptions\MiddlewareException; use Quantum\App\Contracts\RequestStageInterface; use Quantum\App\Exceptions\BaseException; -use Quantum\Middleware\Exceptions\MiddlewareException; use Quantum\Middleware\MiddlewareManager; use Quantum\Router\RouteDispatcher; use Quantum\App\RequestContext; diff --git a/src/App/Traits/WebAppTrait.php b/src/App/Traits/WebAppTrait.php deleted file mode 100644 index 91cb8866..00000000 --- a/src/App/Traits/WebAppTrait.php +++ /dev/null @@ -1,128 +0,0 @@ -find(request()); - - if ($matchedRoute === null) { - return null; - } - - request()->setMatchedRoute($matchedRoute); - - return $matchedRoute; - } - - /** - * Resolve lang config and current locale before controller hooks run. - * @throws LangException|ConfigException|DiException|ReflectionException - */ - private function loadLanguage(): void - { - LangFactory::get(); - } - - /** - * @throws DiException|ReflectionException - */ - private function logDebugInfo(): void - { - $debugbar = debugbar(); - - if ($debugbar->isEnabled()) { - $debugbar->addToStoreCell(Debugger::EVENTS, 'info', event()->getRegistered()); - } - } - - /** - * @throws ConfigException|DiException|ReflectionException - */ - private function setupViewCache(): ViewCache - { - if (!Di::isRegistered(ViewCache::class)) { - Di::register(ViewCache::class); - } - - $viewCache = Di::get(ViewCache::class); - - if ($viewCache->isEnabled()) { - $viewCache->setup(); - } - - return $viewCache; - } - - /** - * @throws ConfigException|DiException|ReflectionException - */ - private function handleCors(Response $response): void - { - if (!config()->has('cors')) { - config()->import(new Setup('config', 'cors')); - } - - foreach (config()->get('cors') as $key => $value) { - $response->setHeader($key, (string) $value); - } - } - - /** - * @throws ConfigException|DiException|ReflectionException|Exception - */ - private function sendResponse(Response $response): void - { - try { - $this->handleCors($response); - $response->send(); - } finally { - $this->cleanupRequestContext(); - } - } - - /** - * Clears request-scoped state after the response has been sent. - */ - private function cleanupRequestContext(): void - { - request()->setMatchedRoute(null); - request()->flush(); - } -} diff --git a/tests/Unit/App/Adapters/WebAppAdapterTest.php b/tests/Unit/App/Adapters/WebAppAdapterTest.php index fbd40ad6..7a0e38a4 100644 --- a/tests/Unit/App/Adapters/WebAppAdapterTest.php +++ b/tests/Unit/App/Adapters/WebAppAdapterTest.php @@ -3,7 +3,6 @@ namespace Quantum\Tests\Unit\App\Adapters; use Quantum\Router\Exceptions\RouteException; -use Quantum\Http\Exceptions\HttpException; use Quantum\App\Adapters\WebAppAdapter; use Quantum\ResourceCache\ViewCache; use Quantum\Tests\Unit\AppTestCase; @@ -13,7 +12,6 @@ use Quantum\Debugger\Debugger; use Quantum\Router\Route; use Quantum\Di\Di; -use Throwable; class WebAppAdapterTest extends AppTestCase { @@ -92,6 +90,32 @@ public function testWebAppAdapterStartHandlesOptionsPreflight(): void $this->assertNull(request()->getUri()); } + public function testWebAppAdapterStartSkipsLanguageLoadingForPreflight(): void + { + request()->create('OPTIONS', '/test/am/tests'); + + $this->assertFalse(config()->has('lang')); + + ob_start(); + $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertFalse(config()->has('lang')); + } + + public function testWebAppAdapterStartSkipsLanguageLoadingForNotFound(): void + { + request()->create('GET', '/non-existing-uri'); + + $this->assertFalse(config()->has('lang')); + + ob_start(); + $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertFalse(config()->has('lang')); + } + public function testWebAppAdapterStartRespondsWithJsonNotFoundForJsonRequests(): void { request()->create('GET', '/non-existing-uri', [], ['Accept' => 'application/json']); @@ -208,78 +232,6 @@ public function testWebAppAdapterStartPropagatesDispatchExceptionWithoutCleanup( $this->assertNotNull(request()->getUri()); } - public function testWebAppAdapterCleansUpOnException(): void - { - request()->create('GET', '/test/am/tests'); - request()->setMatchedRoute(null); - request()->setMatchedRoute(new \Quantum\Router\MatchedRoute( - new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), - [] - )); - response()->setHeader('X-Test', '1'); - response()->json(['foo' => 'bar']); - - $throwingResponse = new class () extends \Quantum\Http\Response { - public function send(): void - { - throw new HttpException('boom'); - } - }; - - try { - $this->invokePrivateMethod($this->webAppAdapter, 'sendResponse', [$throwingResponse]); - $this->fail('Expected response sending to fail.'); - } catch (Throwable $exception) { - $this->assertInstanceOf(HttpException::class, $exception); - } - - $this->assertNull(request()->getMatchedRoute()); - $this->assertNull(request()->getUri()); - $this->assertSame(['foo' => 'bar'], response()->all()); - $this->assertSame('1', response()->getHeader('X-Test')); - $this->assertSame(200, response()->getStatusCode()); - } - - public function testWebAppAdapterLogsRegisteredEventsToDebugger(): void - { - config()->set('app.debug', true); - - $listener = function (): void { - }; - - event()->listen('test.event', $listener); - - $this->invokePrivateMethod($this->webAppAdapter, 'logDebugInfo'); - - $cell = debugbar()->getStoreCell(Debugger::EVENTS); - - $this->assertCount(1, $cell); - $this->assertSame(['test.event' => [$listener]], $cell[0]['info']); - } - - public function testWebAppAdapterLogsNothingToDebuggerWithoutEventListeners(): void - { - config()->set('app.debug', true); - - $this->assertSame([], event()->getRegistered()); - - $this->invokePrivateMethod($this->webAppAdapter, 'logDebugInfo'); - - $this->assertSame([], debugbar()->getStoreCell(Debugger::EVENTS)); - } - - public function testWebAppAdapterSkipsDebuggerEventLogWhenDebugDisabled(): void - { - config()->set('app.debug', false); - - event()->listen('test.event', function (): void { - }); - - $this->invokePrivateMethod($this->webAppAdapter, 'logDebugInfo'); - - $this->assertSame([], debugbar()->getStoreCell(Debugger::EVENTS)); - } - public function testWebAppAdapterBootFiresAppHelperListenerAtModulesBeforeEvent(): void { $this->withTemporaryAppHelper(function (): void { From 0676a80b50dd963a3c43226c16d3c03b084b6805 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:10:47 +0400 Subject: [PATCH 10/21] [#537] Add console pipeline skeleton Introduce ConsoleStageInterface, ConsoleContext and ConsolePipeline as the foundation for running a console command as stages. ConsoleContext carries the Symfony application, input and output and the exit code of one run, using the input and output interfaces so stages can be tested with array input and buffered output. The pipeline runs stages in order and rejects anything that is not a console stage. Nothing uses these classes yet. Co-Authored-By: Claude Sonnet 5.5 --- src/App/ConsoleContext.php | 79 +++++++++ src/App/ConsolePipeline.php | 53 ++++++ src/App/Contracts/ConsoleStageInterface.php | 25 +++ tests/Unit/App/ConsoleContextTest.php | 61 +++++++ tests/Unit/App/ConsolePipelineTest.php | 169 ++++++++++++++++++++ 5 files changed, 387 insertions(+) create mode 100644 src/App/ConsoleContext.php create mode 100644 src/App/ConsolePipeline.php create mode 100644 src/App/Contracts/ConsoleStageInterface.php create mode 100644 tests/Unit/App/ConsoleContextTest.php create mode 100644 tests/Unit/App/ConsolePipelineTest.php diff --git a/src/App/ConsoleContext.php b/src/App/ConsoleContext.php new file mode 100644 index 00000000..bcc650a0 --- /dev/null +++ b/src/App/ConsoleContext.php @@ -0,0 +1,79 @@ +appContext = $appContext; + $this->application = $application; + $this->input = $input; + $this->output = $output; + } + + public function getAppContext(): AppContext + { + return $this->appContext; + } + + public function getApplication(): Application + { + return $this->application; + } + + public function getInput(): InputInterface + { + return $this->input; + } + + public function getOutput(): OutputInterface + { + return $this->output; + } + + public function getExitCode(): int + { + return $this->exitCode; + } + + public function setExitCode(int $exitCode): void + { + $this->exitCode = $exitCode; + } +} diff --git a/src/App/ConsolePipeline.php b/src/App/ConsolePipeline.php new file mode 100644 index 00000000..22db06b7 --- /dev/null +++ b/src/App/ConsolePipeline.php @@ -0,0 +1,53 @@ +stages = $stages; + } + + public function run(ConsoleContext $context): void + { + foreach ($this->stages as $stage) { + $stage->process($context); + } + } +} diff --git a/src/App/Contracts/ConsoleStageInterface.php b/src/App/Contracts/ConsoleStageInterface.php new file mode 100644 index 00000000..214cb2cf --- /dev/null +++ b/src/App/Contracts/ConsoleStageInterface.php @@ -0,0 +1,25 @@ +appContext = App::getContext(); + $this->application = new Application(); + $this->input = new ArrayInput([]); + $this->output = new BufferedOutput(); + + $this->consoleContext = new ConsoleContext($this->appContext, $this->application, $this->input, $this->output); + } + + public function testConsoleContextExposesAppContext(): void + { + $this->assertSame($this->appContext, $this->consoleContext->getAppContext()); + } + + public function testConsoleContextExposesApplicationInputAndOutput(): void + { + $this->assertSame($this->application, $this->consoleContext->getApplication()); + $this->assertSame($this->input, $this->consoleContext->getInput()); + $this->assertSame($this->output, $this->consoleContext->getOutput()); + } + + public function testConsoleContextDefaultsExitCodeToSuccess(): void + { + $this->assertSame(ExitCode::SUCCESS, $this->consoleContext->getExitCode()); + } + + public function testConsoleContextStoresExitCode(): void + { + $this->consoleContext->setExitCode(ExitCode::INVALID); + + $this->assertSame(ExitCode::INVALID, $this->consoleContext->getExitCode()); + } +} diff --git a/tests/Unit/App/ConsolePipelineTest.php b/tests/Unit/App/ConsolePipelineTest.php new file mode 100644 index 00000000..d3f2ff8e --- /dev/null +++ b/tests/Unit/App/ConsolePipelineTest.php @@ -0,0 +1,169 @@ +consoleContext = new ConsoleContext( + $this->createContext(), + new Application(), + new ArrayInput([]), + new BufferedOutput() + ); + } + + public function tearDown(): void + { + $this->clearAppContext(); + } + + public function testPipelineRunsStagesInOrder(): void + { + $log = []; + + $pipeline = new ConsolePipeline([ + $this->createStage(function () use (&$log): void { + $log[] = 'first'; + }), + $this->createStage(function () use (&$log): void { + $log[] = 'second'; + }), + $this->createStage(function () use (&$log): void { + $log[] = 'third'; + }), + ]); + + $pipeline->run($this->consoleContext); + + $this->assertSame(['first', 'second', 'third'], $log); + } + + public function testEmptyPipelineRunsWithoutError(): void + { + $pipeline = new ConsolePipeline([]); + + $pipeline->run($this->consoleContext); + + $this->assertSame(ExitCode::SUCCESS, $this->consoleContext->getExitCode()); + } + + public function testPipelinePassesContextToStages(): void + { + $received = null; + + $pipeline = new ConsolePipeline([ + $this->createStage(function (ConsoleContext $context) use (&$received): void { + $received = $context; + }), + ]); + + $pipeline->run($this->consoleContext); + + $this->assertSame($this->consoleContext, $received); + } + + public function testPipelineCarriesExitCodeBetweenStages(): void + { + $seen = null; + + $pipeline = new ConsolePipeline([ + $this->createStage(function (ConsoleContext $context): void { + $context->setExitCode(ExitCode::FAILURE); + }), + $this->createStage(function (ConsoleContext $context) use (&$seen): void { + $seen = $context->getExitCode(); + }), + ]); + + $pipeline->run($this->consoleContext); + + $this->assertSame(ExitCode::FAILURE, $seen); + $this->assertSame(ExitCode::FAILURE, $this->consoleContext->getExitCode()); + } + + public function testPipelinePropagatesException(): void + { + $pipeline = new ConsolePipeline([ + $this->createStage(function (): void { + throw new RuntimeException('Stage failed'); + }), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stage failed'); + + $pipeline->run($this->consoleContext); + } + + public function testPipelineRejectsInvalidStage(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All stages must implement'); + + new ConsolePipeline([new \stdClass()]); + } + + public function testPipelineRejectsBootStage(): void + { + $bootStage = new class () implements BootStageInterface { + public function process(AppContext $context): void + { + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All stages must implement'); + + new ConsolePipeline([$bootStage]); + } + + public function testPipelineRejectsRequestStage(): void + { + $requestStage = new class () implements RequestStageInterface { + public function process(RequestContext $context): void + { + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All stages must implement'); + + new ConsolePipeline([$requestStage]); + } + + private function createStage(callable $callback): ConsoleStageInterface + { + return new class ($callback) implements ConsoleStageInterface { + private $callback; + + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function process(ConsoleContext $context): void + { + ($this->callback)($context); + } + }; + } +} From 8387105bbbdac5b47672299ca945d0b89c2edae9 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:43:00 +0400 Subject: [PATCH 11/21] [#537] Add console stages Add the stages that run a console command: RegisterCoreCommandsStage and RegisterAppCommandsStage, which share a base that discovers the commands of a folder and adds them to the application, ValidateCommandStage, which fails for a missing or unknown command, and RunCommandStage, which runs the application with the context input and output and stores the exit code. Stage tests use a real non-exiting Symfony application, array input and buffered output. The stages are not wired into ConsoleAppAdapter yet. Co-Authored-By: Claude Sonnet 5.5 --- .../Console/RegisterAppCommandsStage.php | 28 ++++++ .../Stages/Console/RegisterCommandsStage.php | 41 +++++++++ .../Console/RegisterCoreCommandsStage.php | 28 ++++++ src/App/Stages/Console/RunCommandStage.php | 32 +++++++ .../Stages/Console/ValidateCommandStage.php | 34 ++++++++ .../Stages/Console/ConsoleStageTestCase.php | 85 +++++++++++++++++++ .../Console/RegisterAppCommandsStageTest.php | 34 ++++++++ .../Console/RegisterCoreCommandsStageTest.php | 35 ++++++++ .../Stages/Console/RunCommandStageTest.php | 65 ++++++++++++++ .../Console/ValidateCommandStageTest.php | 44 ++++++++++ 10 files changed, 426 insertions(+) create mode 100644 src/App/Stages/Console/RegisterAppCommandsStage.php create mode 100644 src/App/Stages/Console/RegisterCommandsStage.php create mode 100644 src/App/Stages/Console/RegisterCoreCommandsStage.php create mode 100644 src/App/Stages/Console/RunCommandStage.php create mode 100644 src/App/Stages/Console/ValidateCommandStage.php create mode 100644 tests/Unit/App/Stages/Console/ConsoleStageTestCase.php create mode 100644 tests/Unit/App/Stages/Console/RegisterAppCommandsStageTest.php create mode 100644 tests/Unit/App/Stages/Console/RegisterCoreCommandsStageTest.php create mode 100644 tests/Unit/App/Stages/Console/RunCommandStageTest.php create mode 100644 tests/Unit/App/Stages/Console/ValidateCommandStageTest.php diff --git a/src/App/Stages/Console/RegisterAppCommandsStage.php b/src/App/Stages/Console/RegisterAppCommandsStage.php new file mode 100644 index 00000000..e0bae303 --- /dev/null +++ b/src/App/Stages/Console/RegisterAppCommandsStage.php @@ -0,0 +1,28 @@ +getDirectory(), $this->getNamespace()) as $command) { + $instance = new $command['class'](); + + if ($instance instanceof Command) { + $context->getApplication()->add($instance); + } + } + } + + abstract protected function getDirectory(): string; + + abstract protected function getNamespace(): string; +} diff --git a/src/App/Stages/Console/RegisterCoreCommandsStage.php b/src/App/Stages/Console/RegisterCoreCommandsStage.php new file mode 100644 index 00000000..c9edbdec --- /dev/null +++ b/src/App/Stages/Console/RegisterCoreCommandsStage.php @@ -0,0 +1,28 @@ +setExitCode( + $context->getApplication()->run($context->getInput(), $context->getOutput()) + ); + } +} diff --git a/src/App/Stages/Console/ValidateCommandStage.php b/src/App/Stages/Console/ValidateCommandStage.php new file mode 100644 index 00000000..18e6bf04 --- /dev/null +++ b/src/App/Stages/Console/ValidateCommandStage.php @@ -0,0 +1,34 @@ +getInput()->getFirstArgument(); + + if ($commandName === null || !$context->getApplication()->has($commandName)) { + throw new Exception("Command `$commandName` is not defined"); + } + } +} diff --git a/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php b/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php new file mode 100644 index 00000000..d8e5ccb0 --- /dev/null +++ b/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php @@ -0,0 +1,85 @@ + $input + */ + protected function createConsoleContext(array $input = [], ?Application $application = null): ConsoleContext + { + $application ??= new Application(); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + + return new ConsoleContext(App::getContext(), $application, new ArrayInput($input), new BufferedOutput()); + } + + /** + * Creates a temporary command class in the given directory, loads it, runs the test + * with the command name, and removes the file and any directories it had to create. + */ + protected function withTemporaryCommand(string $directory, string $namespace, callable $test): void + { + $suffix = substr(md5(uniqid('', true)), 0, 8); + $className = 'TmpStageCommand' . $suffix; + $commandName = 'tmp:stage' . $suffix; + $file = $directory . DS . $className . '.php'; + + $missing = []; + + for ($dir = $directory; !is_dir($dir); $dir = dirname($dir)) { + array_unshift($missing, $dir); + } + + $command = <<assertTrue(mkdir($directory, 0777, true)); + } + + $this->assertNotFalse(file_put_contents($file, $command)); + + require_once $file; + + $test($commandName); + } finally { + if (is_file($file)) { + unlink($file); + } + + foreach (array_reverse($missing) as $dir) { + if (is_dir($dir)) { + rmdir($dir); + } + } + } + } +} diff --git a/tests/Unit/App/Stages/Console/RegisterAppCommandsStageTest.php b/tests/Unit/App/Stages/Console/RegisterAppCommandsStageTest.php new file mode 100644 index 00000000..0de21f78 --- /dev/null +++ b/tests/Unit/App/Stages/Console/RegisterAppCommandsStageTest.php @@ -0,0 +1,34 @@ +withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $context = $this->createConsoleContext(); + + $this->assertFalse($context->getApplication()->has($commandName)); + + (new RegisterAppCommandsStage())->process($context); + + $this->assertTrue($context->getApplication()->has($commandName)); + }); + } + + public function testRegisterAppCommandsStageSkipsClassesOutsideTheAppNamespace(): void + { + $context = $this->createConsoleContext(); + $commandCount = count($context->getApplication()->all()); + + // tests/_root/shared/Commands holds a fixture in the test namespace, not in Shared\Commands. + (new RegisterAppCommandsStage())->process($context); + + $this->assertCount($commandCount, $context->getApplication()->all()); + } +} diff --git a/tests/Unit/App/Stages/Console/RegisterCoreCommandsStageTest.php b/tests/Unit/App/Stages/Console/RegisterCoreCommandsStageTest.php new file mode 100644 index 00000000..0f97ac12 --- /dev/null +++ b/tests/Unit/App/Stages/Console/RegisterCoreCommandsStageTest.php @@ -0,0 +1,35 @@ +withTemporaryCommand($directory, 'Quantum\Console\Commands', function (string $commandName): void { + $context = $this->createConsoleContext(); + + $this->assertFalse($context->getApplication()->has($commandName)); + + (new RegisterCoreCommandsStage())->process($context); + + $this->assertTrue($context->getApplication()->has($commandName)); + }); + } + + public function testRegisterCoreCommandsStageIgnoresMissingDirectory(): void + { + $this->assertDirectoryDoesNotExist(framework_dir() . DS . 'Console' . DS . 'Commands'); + + $context = $this->createConsoleContext(); + $commandCount = count($context->getApplication()->all()); + + (new RegisterCoreCommandsStage())->process($context); + + $this->assertCount($commandCount, $context->getApplication()->all()); + } +} diff --git a/tests/Unit/App/Stages/Console/RunCommandStageTest.php b/tests/Unit/App/Stages/Console/RunCommandStageTest.php new file mode 100644 index 00000000..a07a25e2 --- /dev/null +++ b/tests/Unit/App/Stages/Console/RunCommandStageTest.php @@ -0,0 +1,65 @@ +createConsoleContext(['command' => 'run:ok'], $this->createApplication('run:ok', 0)); + + (new RunCommandStage())->process($context); + + $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); + } + + public function testRunCommandStageStoresNonZeroExitCode(): void + { + $context = $this->createConsoleContext(['command' => 'run:fail'], $this->createApplication('run:fail', 3)); + + (new RunCommandStage())->process($context); + + $this->assertSame(3, $context->getExitCode()); + } + + public function testRunCommandStageRunsTheCommandWithTheContextOutput(): void + { + $context = $this->createConsoleContext(['command' => 'run:ok'], $this->createApplication('run:ok', 0)); + + (new RunCommandStage())->process($context); + + $this->assertStringContainsString('hello from run:ok', $context->getOutput()->fetch()); + } + + private function createApplication(string $commandName, int $exitCode): Application + { + $application = new Application(); + + $application->add( + (new Command($commandName))->setCode(function (InputInterface $input, OutputInterface $output) use ($commandName, $exitCode): int { + $output->writeln('hello from ' . $commandName); + + return $exitCode; + }) + ); + + return $application; + } +} diff --git a/tests/Unit/App/Stages/Console/ValidateCommandStageTest.php b/tests/Unit/App/Stages/Console/ValidateCommandStageTest.php new file mode 100644 index 00000000..8a863a40 --- /dev/null +++ b/tests/Unit/App/Stages/Console/ValidateCommandStageTest.php @@ -0,0 +1,44 @@ +add(new Command('known:command')); + + $context = $this->createConsoleContext(['command' => 'known:command'], $application); + + (new ValidateCommandStage())->process($context); + + $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); + } + + public function testValidateCommandStageRejectsUnknownCommand(): void + { + $context = $this->createConsoleContext(['command' => 'unknown:command']); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Command `unknown:command` is not defined'); + + (new ValidateCommandStage())->process($context); + } + + public function testValidateCommandStageRejectsMissingCommandName(): void + { + $context = $this->createConsoleContext(); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Command `` is not defined'); + + (new ValidateCommandStage())->process($context); + } +} From 7f6ffa243eefd15e7bfb31cf54b28022b6f10aed Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:13:24 +0400 Subject: [PATCH 12/21] [#537] Run the console command through the console pipeline ConsoleAppAdapter::start() now builds a ConsoleContext and runs a ConsolePipeline of the register core commands, register app commands, validate and run stages instead of the flat sequence of trait methods. createApplication() moves into the adapter and stays overridable, and ConsoleAppTrait is removed because it only held that one method. Behavior is unchanged: the characterization tests written before the refactor pass without edits. One test is added to pin that core commands are registered before app commands. Co-Authored-By: Claude Sonnet 5.5 --- src/App/Adapters/ConsoleAppAdapter.php | 28 +++++-- src/App/Traits/ConsoleAppTrait.php | 78 ------------------- .../App/Adapters/ConsoleAppAdapterTest.php | 31 ++++++++ 3 files changed, 51 insertions(+), 86 deletions(-) delete mode 100644 src/App/Traits/ConsoleAppTrait.php diff --git a/src/App/Adapters/ConsoleAppAdapter.php b/src/App/Adapters/ConsoleAppAdapter.php index 8700ecdd..f9e6270e 100644 --- a/src/App/Adapters/ConsoleAppAdapter.php +++ b/src/App/Adapters/ConsoleAppAdapter.php @@ -10,14 +10,19 @@ namespace Quantum\App\Adapters; +use Quantum\App\Stages\Console\RegisterCoreCommandsStage; +use Quantum\App\Stages\Console\RegisterAppCommandsStage; use Symfony\Component\Console\Output\ConsoleOutput; +use Quantum\App\Stages\Console\ValidateCommandStage; +use Quantum\App\Stages\Console\RunCommandStage; use Symfony\Component\Console\Input\ArgvInput; use Quantum\App\Stages\SetupErrorHandlerStage; use Quantum\App\Stages\LoadEnvironmentStage; use Symfony\Component\Console\Application; use Quantum\App\Stages\LoadAppConfigStage; use Quantum\App\Stages\LoadHelpersStage; -use Quantum\App\Traits\ConsoleAppTrait; +use Quantum\App\ConsolePipeline; +use Quantum\App\ConsoleContext; use Quantum\App\Enums\ExitCode; use Quantum\App\BootPipeline; use Quantum\App\AppContext; @@ -33,8 +38,6 @@ */ class ConsoleAppAdapter extends AppAdapter { - use ConsoleAppTrait; - protected ArgvInput $input; protected ConsoleOutput $output; @@ -73,18 +76,27 @@ public function __construct(AppContext $context) ); } + public function createApplication(string $name, string $version): Application + { + return new Application($name, $version); + } + /** * @throws Exception */ public function start(): ?int { - $this->registerCoreCommands(); - $this->registerAppCommands(); + $context = new ConsoleContext($this->context, $this->application, $this->input, $this->output); - $this->validateCommand(); + $pipeline = new ConsolePipeline([ + new RegisterCoreCommandsStage(), + new RegisterAppCommandsStage(), + new ValidateCommandStage(), + new RunCommandStage(), + ]); - $exitCode = $this->application->run($this->input, $this->output); + $pipeline->run($context); - return $exitCode ?: ExitCode::SUCCESS; + return $context->getExitCode() ?: ExitCode::SUCCESS; } } diff --git a/src/App/Traits/ConsoleAppTrait.php b/src/App/Traits/ConsoleAppTrait.php deleted file mode 100644 index ec28b459..00000000 --- a/src/App/Traits/ConsoleAppTrait.php +++ /dev/null @@ -1,78 +0,0 @@ -registerCommands($directory, $namespace); - } - - /** - * @throws ReflectionException - */ - private function registerAppCommands(): void - { - $directory = base_dir() . DS . 'shared' . DS . 'Commands'; - $namespace = '\\Shared\\Commands\\'; - - $this->registerCommands($directory, $namespace); - } - - /** - * @throws ReflectionException - */ - private function registerCommands(string $directory, string $namespace): void - { - $commands = CommandDiscovery::discover($directory, $namespace); - - foreach ($commands as $command) { - $instance = new $command['class'](); - if ($instance instanceof Command) { - $this->application->add($instance); - } - } - } - - /** - * @throws Exception - */ - private function validateCommand(): void - { - $commandName = $this->input->getFirstArgument(); - - if ($commandName === null || !$this->application->has($commandName)) { - throw new Exception("Command `$commandName` is not defined"); - } - } -} diff --git a/tests/Unit/App/Adapters/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/ConsoleAppAdapterTest.php index ce567091..166db750 100644 --- a/tests/Unit/App/Adapters/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/ConsoleAppAdapterTest.php @@ -113,6 +113,37 @@ public function testConsoleAppAdapterStartRegistersAppCommands(): void }); } + public function testConsoleAppAdapterStartRegistersCoreCommandsBeforeAppCommands(): void + { + $coreDirectory = PROJECT_ROOT . DS . 'vendor' . DS . 'quantum' . DS . 'framework' . DS . 'src' . DS . 'Console' . DS . 'Commands'; + $appDirectory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($coreDirectory, 'Quantum\Console\Commands', function (string $coreCommand) use ($appDirectory): void { + $this->withTemporaryCommand($appDirectory, 'Shared\Commands', function (string $appCommand) use ($coreCommand): void { + $application = new Application('Qt Console Application', 'test'); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + + $adapter = Mockery::mock(ConsoleAppAdapter::class) + ->shouldAllowMockingProtectedMethods() + ->makePartial(); + + $adapter->shouldReceive('createApplication')->andReturn($application); + + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $adapter->__construct($this->createContext()); + $adapter->start(); + + $names = array_keys($application->all()); + + $this->assertContains($coreCommand, $names); + $this->assertContains($appCommand, $names); + $this->assertLessThan(array_search($appCommand, $names, true), array_search($coreCommand, $names, true)); + }); + }); + } + public function testConsoleAppAdapterBootsAllStagesForRegularCommands(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; From e20b97660143c31930f9d16929de3ee91f55827b Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:21:14 +0400 Subject: [PATCH 13/21] [#537] Remove the AppAdapter base class AppAdapter only held the AppContext property and its constructor, and nothing referred to it except the two adapters, which are used through AppInterface. WebAppAdapter and ConsoleAppAdapter now implement AppInterface directly and keep their own context. Co-Authored-By: Claude Sonnet 5.5 --- src/App/Adapters/AppAdapter.php | 28 -------------------------- src/App/Adapters/ConsoleAppAdapter.php | 7 +++++-- src/App/Adapters/WebAppAdapter.php | 7 +++++-- 3 files changed, 10 insertions(+), 32 deletions(-) delete mode 100644 src/App/Adapters/AppAdapter.php diff --git a/src/App/Adapters/AppAdapter.php b/src/App/Adapters/AppAdapter.php deleted file mode 100644 index d7160304..00000000 --- a/src/App/Adapters/AppAdapter.php +++ /dev/null @@ -1,28 +0,0 @@ -context = $context; - } -} diff --git a/src/App/Adapters/ConsoleAppAdapter.php b/src/App/Adapters/ConsoleAppAdapter.php index f9e6270e..7b370c21 100644 --- a/src/App/Adapters/ConsoleAppAdapter.php +++ b/src/App/Adapters/ConsoleAppAdapter.php @@ -21,6 +21,7 @@ use Symfony\Component\Console\Application; use Quantum\App\Stages\LoadAppConfigStage; use Quantum\App\Stages\LoadHelpersStage; +use Quantum\App\Contracts\AppInterface; use Quantum\App\ConsolePipeline; use Quantum\App\ConsoleContext; use Quantum\App\Enums\ExitCode; @@ -36,8 +37,10 @@ * Class ConsoleAppAdapter * @package Quantum\App */ -class ConsoleAppAdapter extends AppAdapter +class ConsoleAppAdapter implements AppInterface { + protected AppContext $context; + protected ArgvInput $input; protected ConsoleOutput $output; @@ -46,7 +49,7 @@ class ConsoleAppAdapter extends AppAdapter public function __construct(AppContext $context) { - parent::__construct($context); + $this->context = $context; $this->input = new ArgvInput(); $this->output = new ConsoleOutput(); diff --git a/src/App/Adapters/WebAppAdapter.php b/src/App/Adapters/WebAppAdapter.php index 025416ef..44d1ef35 100644 --- a/src/App/Adapters/WebAppAdapter.php +++ b/src/App/Adapters/WebAppAdapter.php @@ -20,6 +20,7 @@ use Quantum\App\Stages\LoadEnvironmentStage; use Quantum\App\Stages\LoadAppConfigStage; use Quantum\App\Stages\InitDebuggerStage; +use Quantum\App\Contracts\AppInterface; use Quantum\App\Stages\LoadModulesStage; use Quantum\App\Stages\LoadHelpersStage; use Quantum\App\Stages\InitHttpStage; @@ -33,11 +34,13 @@ * Class WebAppAdapter * @package Quantum\App */ -class WebAppAdapter extends AppAdapter +class WebAppAdapter implements AppInterface { + protected AppContext $context; + public function __construct(AppContext $context) { - parent::__construct($context); + $this->context = $context; $pipeline = new BootPipeline([ new LoadHelpersStage(), From 92a83ce4332d855899e47f4f627f4bad441fab6f Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:36:31 +0400 Subject: [PATCH 14/21] [#537] Organize adapters, contexts, pipelines and boot stages into folders Group each flow in its own folder. The adapter, context and pipeline of the web flow move to App/Adapters/Web and those of the console flow to App/Adapters/Console, and the boot stages move to App/Stages/Boot next to the existing Stages/Request and Stages/Console. Namespaces and imports are updated, the tests move to mirror the source, and LoadHelpersStage now resolves the source root one level higher. Behavior is unchanged: the moved characterization and stage tests keep their assertions and only their namespace and import lines differ. Co-Authored-By: Claude Sonnet 5.5 --- .../{ => Console}/ConsoleAppAdapter.php | 12 +++++------- .../{ => Adapters/Console}/ConsoleContext.php | 3 ++- .../{ => Adapters/Console}/ConsolePipeline.php | 2 +- src/App/{ => Adapters/Web}/RequestContext.php | 3 ++- src/App/{ => Adapters/Web}/RequestPipeline.php | 2 +- src/App/Adapters/{ => Web}/WebAppAdapter.php | 18 ++++++++---------- src/App/Contracts/ConsoleStageInterface.php | 2 +- src/App/Contracts/RequestStageInterface.php | 2 +- src/App/Factories/AppFactory.php | 4 ++-- .../Stages/{ => Boot}/InitDebuggerStage.php | 2 +- src/App/Stages/{ => Boot}/InitHttpStage.php | 2 +- .../Stages/{ => Boot}/LoadAppConfigStage.php | 2 +- .../Stages/{ => Boot}/LoadEnvironmentStage.php | 2 +- src/App/Stages/{ => Boot}/LoadHelpersStage.php | 4 ++-- src/App/Stages/{ => Boot}/LoadModulesStage.php | 2 +- .../{ => Boot}/SetupErrorHandlerStage.php | 2 +- .../Stages/Console/RegisterCommandsStage.php | 2 +- src/App/Stages/Console/RunCommandStage.php | 2 +- .../Stages/Console/ValidateCommandStage.php | 2 +- .../Stages/Request/DispatchRequestStage.php | 2 +- .../Stages/Request/HandlePreflightStage.php | 2 +- .../Request/HandleRouteNotFoundStage.php | 2 +- src/App/Stages/Request/PrepareRequestStage.php | 2 +- src/App/Stages/Request/ResolveRouteStage.php | 2 +- src/App/Stages/Request/SendResponseStage.php | 2 +- .../{ => Console}/ConsoleAppAdapterTest.php | 4 ++-- .../Console}/ConsoleContextTest.php | 4 ++-- .../Console}/ConsolePipelineTest.php | 8 ++++---- .../{ => Adapters/Web}/RequestContextTest.php | 4 ++-- .../{ => Adapters/Web}/RequestPipelineTest.php | 6 +++--- .../Adapters/{ => Web}/WebAppAdapterTest.php | 4 ++-- tests/Unit/App/AppTest.php | 4 ++-- tests/Unit/App/BootPipelineTest.php | 14 +++++++------- tests/Unit/App/Factories/AppFactoryTest.php | 4 ++-- .../{ => Boot}/LoadAppConfigStageTest.php | 8 ++++---- .../{ => Boot}/LoadEnvironmentStageTest.php | 6 +++--- .../Stages/{ => Boot}/LoadHelpersStageTest.php | 4 ++-- .../{ => Boot}/SetupErrorHandlerStageTest.php | 10 +++++----- .../Stages/Console/ConsoleStageTestCase.php | 2 +- .../Request/DispatchRequestStageTest.php | 2 +- .../Request/HandlePreflightStageTest.php | 2 +- .../Request/HandleRouteNotFoundStageTest.php | 2 +- .../Stages/Request/PrepareRequestStageTest.php | 2 +- .../Stages/Request/ResolveRouteStageTest.php | 2 +- .../Stages/Request/SendResponseStageTest.php | 2 +- 45 files changed, 87 insertions(+), 89 deletions(-) rename src/App/Adapters/{ => Console}/ConsoleAppAdapter.php (90%) rename src/App/{ => Adapters/Console}/ConsoleContext.php (96%) rename src/App/{ => Adapters/Console}/ConsolePipeline.php (96%) rename src/App/{ => Adapters/Web}/RequestContext.php (96%) rename src/App/{ => Adapters/Web}/RequestPipeline.php (97%) rename src/App/Adapters/{ => Web}/WebAppAdapter.php (80%) rename src/App/Stages/{ => Boot}/InitDebuggerStage.php (92%) rename src/App/Stages/{ => Boot}/InitHttpStage.php (95%) rename src/App/Stages/{ => Boot}/LoadAppConfigStage.php (95%) rename src/App/Stages/{ => Boot}/LoadEnvironmentStage.php (96%) rename src/App/Stages/{ => Boot}/LoadHelpersStage.php (95%) rename src/App/Stages/{ => Boot}/LoadModulesStage.php (97%) rename src/App/Stages/{ => Boot}/SetupErrorHandlerStage.php (96%) rename tests/Unit/App/Adapters/{ => Console}/ConsoleAppAdapterTest.php (98%) rename tests/Unit/App/{ => Adapters/Console}/ConsoleContextTest.php (94%) rename tests/Unit/App/{ => Adapters/Console}/ConsolePipelineTest.php (96%) rename tests/Unit/App/{ => Adapters/Web}/RequestContextTest.php (95%) rename tests/Unit/App/{ => Adapters/Web}/RequestPipelineTest.php (96%) rename tests/Unit/App/Adapters/{ => Web}/WebAppAdapterTest.php (99%) rename tests/Unit/App/Stages/{ => Boot}/LoadAppConfigStageTest.php (84%) rename tests/Unit/App/Stages/{ => Boot}/LoadEnvironmentStageTest.php (81%) rename tests/Unit/App/Stages/{ => Boot}/LoadHelpersStageTest.php (96%) rename tests/Unit/App/Stages/{ => Boot}/SetupErrorHandlerStageTest.php (78%) diff --git a/src/App/Adapters/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php similarity index 90% rename from src/App/Adapters/ConsoleAppAdapter.php rename to src/App/Adapters/Console/ConsoleAppAdapter.php index 7b370c21..b4127764 100644 --- a/src/App/Adapters/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Adapters; +namespace Quantum\App\Adapters\Console; use Quantum\App\Stages\Console\RegisterCoreCommandsStage; use Quantum\App\Stages\Console\RegisterAppCommandsStage; @@ -16,14 +16,12 @@ use Quantum\App\Stages\Console\ValidateCommandStage; use Quantum\App\Stages\Console\RunCommandStage; use Symfony\Component\Console\Input\ArgvInput; -use Quantum\App\Stages\SetupErrorHandlerStage; -use Quantum\App\Stages\LoadEnvironmentStage; +use Quantum\App\Stages\Boot\SetupErrorHandlerStage; +use Quantum\App\Stages\Boot\LoadEnvironmentStage; use Symfony\Component\Console\Application; -use Quantum\App\Stages\LoadAppConfigStage; -use Quantum\App\Stages\LoadHelpersStage; +use Quantum\App\Stages\Boot\LoadAppConfigStage; +use Quantum\App\Stages\Boot\LoadHelpersStage; use Quantum\App\Contracts\AppInterface; -use Quantum\App\ConsolePipeline; -use Quantum\App\ConsoleContext; use Quantum\App\Enums\ExitCode; use Quantum\App\BootPipeline; use Quantum\App\AppContext; diff --git a/src/App/ConsoleContext.php b/src/App/Adapters/Console/ConsoleContext.php similarity index 96% rename from src/App/ConsoleContext.php rename to src/App/Adapters/Console/ConsoleContext.php index bcc650a0..c97cbe68 100644 --- a/src/App/ConsoleContext.php +++ b/src/App/Adapters/Console/ConsoleContext.php @@ -8,12 +8,13 @@ * @link https://quantumphp.io */ -namespace Quantum\App; +namespace Quantum\App\Adapters\Console; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Application; use Quantum\App\Enums\ExitCode; +use Quantum\App\AppContext; /** * Class ConsoleContext diff --git a/src/App/ConsolePipeline.php b/src/App/Adapters/Console/ConsolePipeline.php similarity index 96% rename from src/App/ConsolePipeline.php rename to src/App/Adapters/Console/ConsolePipeline.php index 22db06b7..15f484da 100644 --- a/src/App/ConsolePipeline.php +++ b/src/App/Adapters/Console/ConsolePipeline.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App; +namespace Quantum\App\Adapters\Console; use Quantum\App\Contracts\ConsoleStageInterface; use InvalidArgumentException; diff --git a/src/App/RequestContext.php b/src/App/Adapters/Web/RequestContext.php similarity index 96% rename from src/App/RequestContext.php rename to src/App/Adapters/Web/RequestContext.php index 31c30a02..fa587a2b 100644 --- a/src/App/RequestContext.php +++ b/src/App/Adapters/Web/RequestContext.php @@ -8,10 +8,11 @@ * @link https://quantumphp.io */ -namespace Quantum\App; +namespace Quantum\App\Adapters\Web; use Quantum\ResourceCache\ViewCache; use Quantum\Router\MatchedRoute; +use Quantum\App\AppContext; use Quantum\Http\Response; /** diff --git a/src/App/RequestPipeline.php b/src/App/Adapters/Web/RequestPipeline.php similarity index 97% rename from src/App/RequestPipeline.php rename to src/App/Adapters/Web/RequestPipeline.php index 389df277..fe56aabe 100644 --- a/src/App/RequestPipeline.php +++ b/src/App/Adapters/Web/RequestPipeline.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App; +namespace Quantum\App\Adapters\Web; use Quantum\App\Contracts\RequestStageInterface; use InvalidArgumentException; diff --git a/src/App/Adapters/WebAppAdapter.php b/src/App/Adapters/Web/WebAppAdapter.php similarity index 80% rename from src/App/Adapters/WebAppAdapter.php rename to src/App/Adapters/Web/WebAppAdapter.php index 44d1ef35..f975070d 100644 --- a/src/App/Adapters/WebAppAdapter.php +++ b/src/App/Adapters/Web/WebAppAdapter.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Adapters; +namespace Quantum\App\Adapters\Web; use Quantum\App\Stages\Request\HandleRouteNotFoundStage; use Quantum\App\Stages\Request\HandlePreflightStage; @@ -16,16 +16,14 @@ use Quantum\App\Stages\Request\PrepareRequestStage; use Quantum\App\Stages\Request\SendResponseStage; use Quantum\App\Stages\Request\ResolveRouteStage; -use Quantum\App\Stages\SetupErrorHandlerStage; -use Quantum\App\Stages\LoadEnvironmentStage; -use Quantum\App\Stages\LoadAppConfigStage; -use Quantum\App\Stages\InitDebuggerStage; +use Quantum\App\Stages\Boot\SetupErrorHandlerStage; +use Quantum\App\Stages\Boot\LoadEnvironmentStage; +use Quantum\App\Stages\Boot\LoadAppConfigStage; +use Quantum\App\Stages\Boot\InitDebuggerStage; use Quantum\App\Contracts\AppInterface; -use Quantum\App\Stages\LoadModulesStage; -use Quantum\App\Stages\LoadHelpersStage; -use Quantum\App\Stages\InitHttpStage; -use Quantum\App\RequestPipeline; -use Quantum\App\RequestContext; +use Quantum\App\Stages\Boot\LoadModulesStage; +use Quantum\App\Stages\Boot\LoadHelpersStage; +use Quantum\App\Stages\Boot\InitHttpStage; use Quantum\App\Enums\ExitCode; use Quantum\App\BootPipeline; use Quantum\App\AppContext; diff --git a/src/App/Contracts/ConsoleStageInterface.php b/src/App/Contracts/ConsoleStageInterface.php index 214cb2cf..5870bd01 100644 --- a/src/App/Contracts/ConsoleStageInterface.php +++ b/src/App/Contracts/ConsoleStageInterface.php @@ -10,7 +10,7 @@ namespace Quantum\App\Contracts; -use Quantum\App\ConsoleContext; +use Quantum\App\Adapters\Console\ConsoleContext; /** * Interface ConsoleStageInterface diff --git a/src/App/Contracts/RequestStageInterface.php b/src/App/Contracts/RequestStageInterface.php index 1506b22e..921529bf 100644 --- a/src/App/Contracts/RequestStageInterface.php +++ b/src/App/Contracts/RequestStageInterface.php @@ -10,7 +10,7 @@ namespace Quantum\App\Contracts; -use Quantum\App\RequestContext; +use Quantum\App\Adapters\Web\RequestContext; /** * Interface RequestStageInterface diff --git a/src/App/Factories/AppFactory.php b/src/App/Factories/AppFactory.php index e9188cfe..f6d565ce 100644 --- a/src/App/Factories/AppFactory.php +++ b/src/App/Factories/AppFactory.php @@ -10,10 +10,10 @@ namespace Quantum\App\Factories; -use Quantum\App\Adapters\ConsoleAppAdapter; +use Quantum\App\Adapters\Console\ConsoleAppAdapter; +use Quantum\App\Adapters\Web\WebAppAdapter; use Quantum\App\Exceptions\BaseException; use Quantum\App\Exceptions\AppException; -use Quantum\App\Adapters\WebAppAdapter; use Quantum\App\Enums\AppType; use Quantum\App\AppContext; use Quantum\Di\DiContainer; diff --git a/src/App/Stages/InitDebuggerStage.php b/src/App/Stages/Boot/InitDebuggerStage.php similarity index 92% rename from src/App/Stages/InitDebuggerStage.php rename to src/App/Stages/Boot/InitDebuggerStage.php index 80cdd3ef..5379b3a4 100644 --- a/src/App/Stages/InitDebuggerStage.php +++ b/src/App/Stages/Boot/InitDebuggerStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages; +namespace Quantum\App\Stages\Boot; use Quantum\App\Contracts\BootStageInterface; use Quantum\App\AppContext; diff --git a/src/App/Stages/InitHttpStage.php b/src/App/Stages/Boot/InitHttpStage.php similarity index 95% rename from src/App/Stages/InitHttpStage.php rename to src/App/Stages/Boot/InitHttpStage.php index ff893653..2bdf5175 100644 --- a/src/App/Stages/InitHttpStage.php +++ b/src/App/Stages/Boot/InitHttpStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages; +namespace Quantum\App\Stages\Boot; use Quantum\App\Contracts\BootStageInterface; use Quantum\App\AppContext; diff --git a/src/App/Stages/LoadAppConfigStage.php b/src/App/Stages/Boot/LoadAppConfigStage.php similarity index 95% rename from src/App/Stages/LoadAppConfigStage.php rename to src/App/Stages/Boot/LoadAppConfigStage.php index f2944bdf..3c5555e9 100644 --- a/src/App/Stages/LoadAppConfigStage.php +++ b/src/App/Stages/Boot/LoadAppConfigStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages; +namespace Quantum\App\Stages\Boot; use Quantum\App\Contracts\BootStageInterface; use Quantum\Config\Exceptions\ConfigException; diff --git a/src/App/Stages/LoadEnvironmentStage.php b/src/App/Stages/Boot/LoadEnvironmentStage.php similarity index 96% rename from src/App/Stages/LoadEnvironmentStage.php rename to src/App/Stages/Boot/LoadEnvironmentStage.php index 16f935cc..2d048522 100644 --- a/src/App/Stages/LoadEnvironmentStage.php +++ b/src/App/Stages/Boot/LoadEnvironmentStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages; +namespace Quantum\App\Stages\Boot; use Quantum\Environment\Exceptions\EnvException; use Quantum\App\Contracts\BootStageInterface; diff --git a/src/App/Stages/LoadHelpersStage.php b/src/App/Stages/Boot/LoadHelpersStage.php similarity index 95% rename from src/App/Stages/LoadHelpersStage.php rename to src/App/Stages/Boot/LoadHelpersStage.php index 6f8b7821..ad696f5b 100644 --- a/src/App/Stages/LoadHelpersStage.php +++ b/src/App/Stages/Boot/LoadHelpersStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages; +namespace Quantum\App\Stages\Boot; use Quantum\App\Contracts\BootStageInterface; use Quantum\App\AppContext; @@ -29,7 +29,7 @@ public function process(AppContext $context): void private function loadComponentHelpers(): void { - $srcDir = dirname(__DIR__, 2); + $srcDir = dirname(__DIR__, 3); $componentDirs = glob($srcDir . DS . '*', GLOB_ONLYDIR); diff --git a/src/App/Stages/LoadModulesStage.php b/src/App/Stages/Boot/LoadModulesStage.php similarity index 97% rename from src/App/Stages/LoadModulesStage.php rename to src/App/Stages/Boot/LoadModulesStage.php index c75ff5c7..87fda24f 100644 --- a/src/App/Stages/LoadModulesStage.php +++ b/src/App/Stages/Boot/LoadModulesStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages; +namespace Quantum\App\Stages\Boot; use Quantum\Module\Exceptions\ModuleException; use Quantum\Router\Exceptions\RouteException; diff --git a/src/App/Stages/SetupErrorHandlerStage.php b/src/App/Stages/Boot/SetupErrorHandlerStage.php similarity index 96% rename from src/App/Stages/SetupErrorHandlerStage.php rename to src/App/Stages/Boot/SetupErrorHandlerStage.php index 88097abb..583371bb 100644 --- a/src/App/Stages/SetupErrorHandlerStage.php +++ b/src/App/Stages/Boot/SetupErrorHandlerStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages; +namespace Quantum\App\Stages\Boot; use Quantum\Config\Exceptions\ConfigException; use Quantum\App\Contracts\BootStageInterface; diff --git a/src/App/Stages/Console/RegisterCommandsStage.php b/src/App/Stages/Console/RegisterCommandsStage.php index a956c78f..ef83c6ef 100644 --- a/src/App/Stages/Console/RegisterCommandsStage.php +++ b/src/App/Stages/Console/RegisterCommandsStage.php @@ -13,7 +13,7 @@ use Symfony\Component\Console\Command\Command; use Quantum\App\Contracts\ConsoleStageInterface; use Quantum\Console\CommandDiscovery; -use Quantum\App\ConsoleContext; +use Quantum\App\Adapters\Console\ConsoleContext; /** * Class RegisterCommandsStage diff --git a/src/App/Stages/Console/RunCommandStage.php b/src/App/Stages/Console/RunCommandStage.php index 0865ca3f..4f03545d 100644 --- a/src/App/Stages/Console/RunCommandStage.php +++ b/src/App/Stages/Console/RunCommandStage.php @@ -11,7 +11,7 @@ namespace Quantum\App\Stages\Console; use Quantum\App\Contracts\ConsoleStageInterface; -use Quantum\App\ConsoleContext; +use Quantum\App\Adapters\Console\ConsoleContext; use Exception; /** diff --git a/src/App/Stages/Console/ValidateCommandStage.php b/src/App/Stages/Console/ValidateCommandStage.php index 18e6bf04..d5b13ca6 100644 --- a/src/App/Stages/Console/ValidateCommandStage.php +++ b/src/App/Stages/Console/ValidateCommandStage.php @@ -11,7 +11,7 @@ namespace Quantum\App\Stages\Console; use Quantum\App\Contracts\ConsoleStageInterface; -use Quantum\App\ConsoleContext; +use Quantum\App\Adapters\Console\ConsoleContext; use Exception; /** diff --git a/src/App/Stages/Request/DispatchRequestStage.php b/src/App/Stages/Request/DispatchRequestStage.php index c2d758e8..1781f562 100644 --- a/src/App/Stages/Request/DispatchRequestStage.php +++ b/src/App/Stages/Request/DispatchRequestStage.php @@ -15,7 +15,7 @@ use Quantum\App\Exceptions\BaseException; use Quantum\Middleware\MiddlewareManager; use Quantum\Router\RouteDispatcher; -use Quantum\App\RequestContext; +use Quantum\App\Adapters\Web\RequestContext; use Quantum\Http\Response; use Quantum\Http\Request; use RuntimeException; diff --git a/src/App/Stages/Request/HandlePreflightStage.php b/src/App/Stages/Request/HandlePreflightStage.php index 8f493e95..c99cdd85 100644 --- a/src/App/Stages/Request/HandlePreflightStage.php +++ b/src/App/Stages/Request/HandlePreflightStage.php @@ -12,7 +12,7 @@ use Quantum\App\Contracts\RequestStageInterface; use Quantum\Http\Enums\StatusCode; -use Quantum\App\RequestContext; +use Quantum\App\Adapters\Web\RequestContext; /** * Class HandlePreflightStage diff --git a/src/App/Stages/Request/HandleRouteNotFoundStage.php b/src/App/Stages/Request/HandleRouteNotFoundStage.php index bd9b5b23..a405e11d 100644 --- a/src/App/Stages/Request/HandleRouteNotFoundStage.php +++ b/src/App/Stages/Request/HandleRouteNotFoundStage.php @@ -14,7 +14,7 @@ use Quantum\Config\Exceptions\ConfigException; use Quantum\App\Exceptions\BaseException; use Quantum\Di\Exceptions\DiException; -use Quantum\App\RequestContext; +use Quantum\App\Adapters\Web\RequestContext; use ReflectionException; /** diff --git a/src/App/Stages/Request/PrepareRequestStage.php b/src/App/Stages/Request/PrepareRequestStage.php index 66586459..ddcfeabc 100644 --- a/src/App/Stages/Request/PrepareRequestStage.php +++ b/src/App/Stages/Request/PrepareRequestStage.php @@ -16,7 +16,7 @@ use Quantum\Lang\Factories\LangFactory; use Quantum\Di\Exceptions\DiException; use Quantum\ResourceCache\ViewCache; -use Quantum\App\RequestContext; +use Quantum\App\Adapters\Web\RequestContext; use Quantum\Debugger\Debugger; use ReflectionException; use Quantum\Di\Di; diff --git a/src/App/Stages/Request/ResolveRouteStage.php b/src/App/Stages/Request/ResolveRouteStage.php index 020fadba..729ce8ec 100644 --- a/src/App/Stages/Request/ResolveRouteStage.php +++ b/src/App/Stages/Request/ResolveRouteStage.php @@ -14,7 +14,7 @@ use Quantum\Router\Exceptions\RouteException; use Quantum\App\Exceptions\BaseException; use Quantum\Router\RouteCollection; -use Quantum\App\RequestContext; +use Quantum\App\Adapters\Web\RequestContext; use Quantum\Router\RouteFinder; use Quantum\Di\Di; diff --git a/src/App/Stages/Request/SendResponseStage.php b/src/App/Stages/Request/SendResponseStage.php index 1a62b2a4..d1d61519 100644 --- a/src/App/Stages/Request/SendResponseStage.php +++ b/src/App/Stages/Request/SendResponseStage.php @@ -13,7 +13,7 @@ use Quantum\App\Contracts\RequestStageInterface; use Quantum\Config\Exceptions\ConfigException; use Quantum\Di\Exceptions\DiException; -use Quantum\App\RequestContext; +use Quantum\App\Adapters\Web\RequestContext; use Quantum\Http\Response; use Quantum\Config\Setup; use ReflectionException; diff --git a/tests/Unit/App/Adapters/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php similarity index 98% rename from tests/Unit/App/Adapters/ConsoleAppAdapterTest.php rename to tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index 166db750..d54772d4 100644 --- a/tests/Unit/App/Adapters/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -1,8 +1,8 @@ Date: Fri, 9 Oct 2026 18:43:41 +0400 Subject: [PATCH 15/21] [#537] Document the runtime pipelines in the changelog Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21f90c87..92709052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ The format is based on Keep a Changelog. - Added adapter-based Lang provider support with `DeepL` and `Google Translate` adapters plus shared remote request/caching infrastructure (#533) ### Changed +- **BREAKING:** Refactored the web and console runtime into staged pipelines (#537): + - `WebAppAdapter::start()` now runs a `RequestPipeline` of the preflight, route resolution, not found, prepare, dispatch, and send stages with a `RequestContext`, and `ConsoleAppAdapter::start()` runs a `ConsolePipeline` of the register core commands, register app commands, validate, and run stages with a `ConsoleContext`. Behavior is unchanged, and runtime stage events are not dispatched yet. + - Moved the web adapter, context, and pipeline to `Quantum\App\Adapters\Web`, the console ones to `Quantum\App\Adapters\Console`, and the boot stages to `Quantum\App\Stages\Boot`. Code that goes through `AppFactory` is unaffected; code that references these classes directly must update its imports. + - Removed the `AppAdapter` base class and the `WebAppTrait` and `ConsoleAppTrait` traits. `WebAppAdapter` and `ConsoleAppAdapter` now implement `AppInterface` directly, and `ConsoleAppAdapter::createApplication()` stays public. - **BREAKING:** Replaced the `Quantum\Hook` package with `Quantum\Event` (#536): - `hook()` is now `event()`; `on()` and `fire()` are now `listen()` and `dispatch()`, and no aliases remain. Listeners receive one named payload array, which is empty when none is given. - Event names no longer need to be registered, so `shared/config/hooks.php` is no longer read; dispatching a name without listeners does nothing and an empty name throws `EventException`. From 2dc86ddf70b5802e808d67680561f32524348c93 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:01:43 +0400 Subject: [PATCH 16/21] [#537] Pin command registration behavior before changing it Add characterization tests for the command registration done by the console adapter: an app command overrides a core command with the same name, a missing app commands directory is tolerated, and only instantiable commands are registered. They pass on the current code and guard the upcoming rework of the register stages. Co-Authored-By: Claude Sonnet 5.5 --- .../Console/ConsoleAppAdapterTest.php | 110 +++++++++++++++--- 1 file changed, 97 insertions(+), 13 deletions(-) diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index d54772d4..0be1b04a 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -144,6 +144,52 @@ public function testConsoleAppAdapterStartRegistersCoreCommandsBeforeAppCommands }); } + public function testConsoleAppAdapterStartLetsAppCommandOverrideCoreCommandWithSameName(): void + { + $coreDirectory = PROJECT_ROOT . DS . 'vendor' . DS . 'quantum' . DS . 'framework' . DS . 'src' . DS . 'Console' . DS . 'Commands'; + $appDirectory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($coreDirectory, 'Quantum\Console\Commands', function (string $sharedName) use ($appDirectory): void { + $this->withTemporaryCommand($appDirectory, 'Shared\Commands', function (string $commandName): void { + $this->assertSame(0, $this->startWithRealApplication($commandName)); + $this->assertSame('Shared\Commands', $GLOBALS['tmpCommandSource'] ?? null); + }, $sharedName); + }); + } + + public function testConsoleAppAdapterStartWorksWithoutAppCommandsDirectory(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + $hidden = PROJECT_ROOT . DS . 'shared' . DS . 'Commands_hidden'; + + $this->assertTrue(rename($directory, $hidden)); + + try { + $this->assertSame(0, $this->startWithRealApplication('list')); + } finally { + rename($hidden, $directory); + } + } + + public function testConsoleAppAdapterStartRegistersOnlyInstantiableCommands(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $before = $this->registeredCommandNames(); + + $plain = 'TmpPlain' . substr(md5(uniqid('', true)), 0, 8); + $abstract = 'TmpAbstract' . substr(md5(uniqid('', true)), 0, 8); + + $plainCode = "withTemporaryFile($directory, $plain, $plainCode, function () use ($directory, $abstract, $abstractCode, $before): void { + $this->withTemporaryFile($directory, $abstract, $abstractCode, function () use ($before): void { + $this->assertSame($before, $this->registeredCommandNames()); + }); + }); + } + public function testConsoleAppAdapterBootsAllStagesForRegularCommands(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; @@ -189,23 +235,41 @@ private function startWithRealApplication(string $commandName): ?int return $adapter->start(); } + /** + * Returns the names of the commands that start() leaves registered on a real Symfony Application. + * @return array + */ + private function registeredCommandNames(): array + { + $application = new Application('Qt Console Application', 'test'); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + + $adapter = Mockery::mock(ConsoleAppAdapter::class) + ->shouldAllowMockingProtectedMethods() + ->makePartial(); + + $adapter->shouldReceive('createApplication')->andReturn($application); + + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $adapter->__construct($this->createContext()); + $adapter->start(); + + return array_keys($application->all()); + } + /** * Creates a temporary command class in the given directory, loads it, runs the test * with the command name, and removes the file and any directories it had to create. - * The command records that it ran in $GLOBALS['tmpCommandRan']. + * The command records that it ran, and from which namespace, in $GLOBALS['tmpCommandRan'] and + * $GLOBALS['tmpCommandSource']. A fixed command name creates two commands with the same name. */ - private function withTemporaryCommand(string $directory, string $namespace, callable $test): void + private function withTemporaryCommand(string $directory, string $namespace, callable $test, ?string $name = null): void { $suffix = substr(md5(uniqid('', true)), 0, 8); $className = 'TmpCommand' . $suffix; - $commandName = 'tmp:command' . $suffix; - $file = $directory . DS . $className . '.php'; - - $missing = []; - - for ($dir = $directory; !is_dir($dir); $dir = dirname($dir)) { - array_unshift($missing, $dir); - } + $commandName = $name ?? 'tmp:command' . $suffix; $command = <<withTemporaryFile($directory, $className, $command, function () use ($test, $commandName): void { + $test($commandName); + }); + } + + /** + * Writes a temporary PHP file named after the class into the directory, loads it, runs the test, + * and removes the file and any directories it had to create. + */ + private function withTemporaryFile(string $directory, string $className, string $code, callable $test): void + { + $file = $directory . DS . $className . '.php'; + + $missing = []; + + for ($dir = $directory; !is_dir($dir); $dir = dirname($dir)) { + array_unshift($missing, $dir); + } + try { if ($missing !== []) { $this->assertTrue(mkdir($directory, 0777, true)); } - $this->assertNotFalse(file_put_contents($file, $command)); + $this->assertNotFalse(file_put_contents($file, $code)); require_once $file; - $test($commandName); + $test(); } finally { if (is_file($file)) { unlink($file); @@ -248,7 +332,7 @@ public function exec(): void } } - unset($GLOBALS['tmpCommandRan']); + unset($GLOBALS['tmpCommandRan'], $GLOBALS['tmpCommandSource']); } } } From 5630e4dc147c9ec8c34302f17ffc9aa6ad27f6cb Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:13:29 +0400 Subject: [PATCH 17/21] [#537] Add CommandRegistrar to register discovered commands Add a CommandRegistrar in the Console package that discovers the commands of a directory and adds them to the console application. It is the single place that connects the native command discovery to the Symfony application. Nothing uses it yet; the register stages will call it in the next commit. Co-Authored-By: Claude Sonnet 5.5 --- src/Console/CommandRegistrar.php | 39 ++++++++++++ tests/Unit/Console/CommandRegistrarTest.php | 66 +++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 src/Console/CommandRegistrar.php create mode 100644 tests/Unit/Console/CommandRegistrarTest.php diff --git a/src/Console/CommandRegistrar.php b/src/Console/CommandRegistrar.php new file mode 100644 index 00000000..f7c9bd97 --- /dev/null +++ b/src/Console/CommandRegistrar.php @@ -0,0 +1,39 @@ +add($instance); + } + } + } +} diff --git a/tests/Unit/Console/CommandRegistrarTest.php b/tests/Unit/Console/CommandRegistrarTest.php new file mode 100644 index 00000000..6fb0483f --- /dev/null +++ b/tests/Unit/Console/CommandRegistrarTest.php @@ -0,0 +1,66 @@ +application = new Application('Qt Console Application', 'test'); + + $this->directory = base_dir() . DS . 'shared' . DS . 'Commands'; + } + + public function testCommandRegistrarAddsDiscoveredCommands(): void + { + $this->assertFalse($this->application->has('test:dummy')); + + CommandRegistrar::register($this->application, $this->directory, $this->namespace); + + $this->assertTrue($this->application->has('test:dummy')); + $this->assertInstanceOf(TestCommand::class, $this->application->get('test:dummy')); + } + + public function testCommandRegistrarIgnoresMissingDirectory(): void + { + $before = array_keys($this->application->all()); + + CommandRegistrar::register($this->application, $this->directory . DS . 'missing', $this->namespace); + + $this->assertSame($before, array_keys($this->application->all())); + } + + public function testCommandRegistrarIgnoresClassesOutsideTheNamespace(): void + { + $before = array_keys($this->application->all()); + + CommandRegistrar::register($this->application, $this->directory, '\\Some\\Other\\Namespace\\'); + + $this->assertSame($before, array_keys($this->application->all())); + } + + public function testCommandRegistrarReplacesAnExistingCommandWithTheSameName(): void + { + $this->application->add(new Command('test:dummy')); + + $this->assertNotInstanceOf(TestCommand::class, $this->application->get('test:dummy')); + + CommandRegistrar::register($this->application, $this->directory, $this->namespace); + + $this->assertInstanceOf(TestCommand::class, $this->application->get('test:dummy')); + } +} From 7d0450ac9265c66b25d4cf7bb9e9a4e59d22c7f3 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:21:29 +0400 Subject: [PATCH 18/21] [#537] Register the console commands through the CommandRegistrar Give RegisterCoreCommandsStage and RegisterAppCommandsStage their own process() that calls the CommandRegistrar with their directory and namespace, and remove the abstract RegisterCommandsStage they used to inherit it from. Behavior is unchanged: core commands are still registered before app commands. Co-Authored-By: Claude Sonnet 5.5 --- .../Console/RegisterAppCommandsStage.php | 24 +++++++---- .../Stages/Console/RegisterCommandsStage.php | 41 ------------------- .../Console/RegisterCoreCommandsStage.php | 24 +++++++---- 3 files changed, 32 insertions(+), 57 deletions(-) delete mode 100644 src/App/Stages/Console/RegisterCommandsStage.php diff --git a/src/App/Stages/Console/RegisterAppCommandsStage.php b/src/App/Stages/Console/RegisterAppCommandsStage.php index e0bae303..eb9db33f 100644 --- a/src/App/Stages/Console/RegisterAppCommandsStage.php +++ b/src/App/Stages/Console/RegisterAppCommandsStage.php @@ -10,19 +10,27 @@ namespace Quantum\App\Stages\Console; +use Quantum\App\Contracts\ConsoleStageInterface; +use Quantum\App\Adapters\Console\ConsoleContext; +use Quantum\Console\CommandRegistrar; +use ReflectionException; + /** * Class RegisterAppCommandsStage * @package Quantum\App */ -class RegisterAppCommandsStage extends RegisterCommandsStage +class RegisterAppCommandsStage implements ConsoleStageInterface { - protected function getDirectory(): string - { - return base_dir() . DS . 'shared' . DS . 'Commands'; - } - - protected function getNamespace(): string + /** + * Registers the commands of the application + * @throws ReflectionException + */ + public function process(ConsoleContext $context): void { - return '\\Shared\\Commands\\'; + CommandRegistrar::register( + $context->getApplication(), + base_dir() . DS . 'shared' . DS . 'Commands', + '\\Shared\\Commands\\' + ); } } diff --git a/src/App/Stages/Console/RegisterCommandsStage.php b/src/App/Stages/Console/RegisterCommandsStage.php deleted file mode 100644 index ef83c6ef..00000000 --- a/src/App/Stages/Console/RegisterCommandsStage.php +++ /dev/null @@ -1,41 +0,0 @@ -getDirectory(), $this->getNamespace()) as $command) { - $instance = new $command['class'](); - - if ($instance instanceof Command) { - $context->getApplication()->add($instance); - } - } - } - - abstract protected function getDirectory(): string; - - abstract protected function getNamespace(): string; -} diff --git a/src/App/Stages/Console/RegisterCoreCommandsStage.php b/src/App/Stages/Console/RegisterCoreCommandsStage.php index c9edbdec..e3ebee7b 100644 --- a/src/App/Stages/Console/RegisterCoreCommandsStage.php +++ b/src/App/Stages/Console/RegisterCoreCommandsStage.php @@ -10,19 +10,27 @@ namespace Quantum\App\Stages\Console; +use Quantum\App\Contracts\ConsoleStageInterface; +use Quantum\App\Adapters\Console\ConsoleContext; +use Quantum\Console\CommandRegistrar; +use ReflectionException; + /** * Class RegisterCoreCommandsStage * @package Quantum\App */ -class RegisterCoreCommandsStage extends RegisterCommandsStage +class RegisterCoreCommandsStage implements ConsoleStageInterface { - protected function getDirectory(): string - { - return framework_dir() . DS . 'Console' . DS . 'Commands'; - } - - protected function getNamespace(): string + /** + * Registers the commands that ship with the framework + * @throws ReflectionException + */ + public function process(ConsoleContext $context): void { - return '\\Quantum\\Console\\Commands\\'; + CommandRegistrar::register( + $context->getApplication(), + framework_dir() . DS . 'Console' . DS . 'Commands', + '\\Quantum\\Console\\Commands\\' + ); } } From 4b66434c0515bd81b4b2bbe470eaf58619228b3a Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:38:25 +0400 Subject: [PATCH 19/21] [#537] Keep the register stage paths in small helpers Move the directory and the namespace of the core and app register stages into private helpers, so process() only reads as the call to the CommandRegistrar. Behavior is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .../Stages/Console/RegisterAppCommandsStage.php | 14 ++++++++++++-- .../Stages/Console/RegisterCoreCommandsStage.php | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/App/Stages/Console/RegisterAppCommandsStage.php b/src/App/Stages/Console/RegisterAppCommandsStage.php index eb9db33f..303085c6 100644 --- a/src/App/Stages/Console/RegisterAppCommandsStage.php +++ b/src/App/Stages/Console/RegisterAppCommandsStage.php @@ -29,8 +29,18 @@ public function process(ConsoleContext $context): void { CommandRegistrar::register( $context->getApplication(), - base_dir() . DS . 'shared' . DS . 'Commands', - '\\Shared\\Commands\\' + $this->directory(), + $this->namespace(), ); } + + private function namespace(): string + { + return '\\Shared\\Commands\\'; + } + + private function directory(): string + { + return base_dir() . DS . 'shared' . DS . 'Commands'; + } } diff --git a/src/App/Stages/Console/RegisterCoreCommandsStage.php b/src/App/Stages/Console/RegisterCoreCommandsStage.php index e3ebee7b..63b9c91c 100644 --- a/src/App/Stages/Console/RegisterCoreCommandsStage.php +++ b/src/App/Stages/Console/RegisterCoreCommandsStage.php @@ -29,8 +29,18 @@ public function process(ConsoleContext $context): void { CommandRegistrar::register( $context->getApplication(), - framework_dir() . DS . 'Console' . DS . 'Commands', - '\\Quantum\\Console\\Commands\\' + $this->directory(), + $this->namespace(), ); } + + private function namespace(): string + { + return '\\Quantum\\Console\\Commands\\'; + } + + private function directory(): string + { + return framework_dir() . DS . 'Console' . DS . 'Commands'; + } } From 7fb48375467c6cf23e3d84f6f9bef71d45c99ca2 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:38:26 +0400 Subject: [PATCH 20/21] [#537] Validate the command inside RunCommandStage Reject a missing or unknown command at the start of RunCommandStage and remove ValidateCommandStage, which was a guard clause and not a stage of its own. The exception and its message are unchanged, and nothing runs before the check. Co-Authored-By: Claude Sonnet 5.5 --- .../Adapters/Console/ConsoleAppAdapter.php | 2 - src/App/Stages/Console/RunCommandStage.php | 9 ++++ .../Stages/Console/ValidateCommandStage.php | 34 -------------- .../Stages/Console/RunCommandStageTest.php | 35 +++++++++++++++ .../Console/ValidateCommandStageTest.php | 44 ------------------- 5 files changed, 44 insertions(+), 80 deletions(-) delete mode 100644 src/App/Stages/Console/ValidateCommandStage.php delete mode 100644 tests/Unit/App/Stages/Console/ValidateCommandStageTest.php diff --git a/src/App/Adapters/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index b4127764..7d511613 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -13,7 +13,6 @@ use Quantum\App\Stages\Console\RegisterCoreCommandsStage; use Quantum\App\Stages\Console\RegisterAppCommandsStage; use Symfony\Component\Console\Output\ConsoleOutput; -use Quantum\App\Stages\Console\ValidateCommandStage; use Quantum\App\Stages\Console\RunCommandStage; use Symfony\Component\Console\Input\ArgvInput; use Quantum\App\Stages\Boot\SetupErrorHandlerStage; @@ -92,7 +91,6 @@ public function start(): ?int $pipeline = new ConsolePipeline([ new RegisterCoreCommandsStage(), new RegisterAppCommandsStage(), - new ValidateCommandStage(), new RunCommandStage(), ]); diff --git a/src/App/Stages/Console/RunCommandStage.php b/src/App/Stages/Console/RunCommandStage.php index 4f03545d..fb8cc28c 100644 --- a/src/App/Stages/Console/RunCommandStage.php +++ b/src/App/Stages/Console/RunCommandStage.php @@ -16,6 +16,9 @@ /** * Class RunCommandStage + * + * Runs the requested command; a missing or unknown command is rejected before anything runs. + * * @package Quantum\App */ class RunCommandStage implements ConsoleStageInterface @@ -25,6 +28,12 @@ class RunCommandStage implements ConsoleStageInterface */ public function process(ConsoleContext $context): void { + $commandName = $context->getInput()->getFirstArgument(); + + if ($commandName === null || !$context->getApplication()->has($commandName)) { + throw new Exception("Command `$commandName` is not defined"); + } + $context->setExitCode( $context->getApplication()->run($context->getInput(), $context->getOutput()) ); diff --git a/src/App/Stages/Console/ValidateCommandStage.php b/src/App/Stages/Console/ValidateCommandStage.php deleted file mode 100644 index d5b13ca6..00000000 --- a/src/App/Stages/Console/ValidateCommandStage.php +++ /dev/null @@ -1,34 +0,0 @@ -getInput()->getFirstArgument(); - - if ($commandName === null || !$context->getApplication()->has($commandName)) { - throw new Exception("Command `$commandName` is not defined"); - } - } -} diff --git a/tests/Unit/App/Stages/Console/RunCommandStageTest.php b/tests/Unit/App/Stages/Console/RunCommandStageTest.php index a07a25e2..c823b510 100644 --- a/tests/Unit/App/Stages/Console/RunCommandStageTest.php +++ b/tests/Unit/App/Stages/Console/RunCommandStageTest.php @@ -8,6 +8,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Application; use Quantum\App\Enums\ExitCode; +use Exception; class RunCommandStageTest extends ConsoleStageTestCase { @@ -48,6 +49,40 @@ public function testRunCommandStageRunsTheCommandWithTheContextOutput(): void $this->assertStringContainsString('hello from run:ok', $context->getOutput()->fetch()); } + public function testRunCommandStageRejectsUnknownCommand(): void + { + $context = $this->createConsoleContext(['command' => 'unknown:command'], $this->createApplication('run:ok', 0)); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Command `unknown:command` is not defined'); + + (new RunCommandStage())->process($context); + } + + public function testRunCommandStageRejectsMissingCommandName(): void + { + $context = $this->createConsoleContext([], $this->createApplication('run:ok', 0)); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Command `` is not defined'); + + (new RunCommandStage())->process($context); + } + + public function testRunCommandStageRunsNothingWhenTheCommandNameIsMissing(): void + { + $context = $this->createConsoleContext([], $this->createApplication('run:fail', 3)); + + try { + (new RunCommandStage())->process($context); + } catch (Exception $e) { + // expected, the command name is missing + } + + $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); + $this->assertSame('', $context->getOutput()->fetch()); + } + private function createApplication(string $commandName, int $exitCode): Application { $application = new Application(); diff --git a/tests/Unit/App/Stages/Console/ValidateCommandStageTest.php b/tests/Unit/App/Stages/Console/ValidateCommandStageTest.php deleted file mode 100644 index 8a863a40..00000000 --- a/tests/Unit/App/Stages/Console/ValidateCommandStageTest.php +++ /dev/null @@ -1,44 +0,0 @@ -add(new Command('known:command')); - - $context = $this->createConsoleContext(['command' => 'known:command'], $application); - - (new ValidateCommandStage())->process($context); - - $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); - } - - public function testValidateCommandStageRejectsUnknownCommand(): void - { - $context = $this->createConsoleContext(['command' => 'unknown:command']); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Command `unknown:command` is not defined'); - - (new ValidateCommandStage())->process($context); - } - - public function testValidateCommandStageRejectsMissingCommandName(): void - { - $context = $this->createConsoleContext(); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Command `` is not defined'); - - (new ValidateCommandStage())->process($context); - } -} From 3343a05252ea596354a59e67a81610adc4c79fdd Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:41:24 +0400 Subject: [PATCH 21/21] [#537] Update the changelog for the console pipeline stages The console pipeline no longer has a validate stage: the run stage rejects a missing or unknown command, and the commands are registered through the new CommandRegistrar. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92709052..70e2b781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ The format is based on Keep a Changelog. ### Changed - **BREAKING:** Refactored the web and console runtime into staged pipelines (#537): - - `WebAppAdapter::start()` now runs a `RequestPipeline` of the preflight, route resolution, not found, prepare, dispatch, and send stages with a `RequestContext`, and `ConsoleAppAdapter::start()` runs a `ConsolePipeline` of the register core commands, register app commands, validate, and run stages with a `ConsoleContext`. Behavior is unchanged, and runtime stage events are not dispatched yet. + - `WebAppAdapter::start()` now runs a `RequestPipeline` of the preflight, route resolution, not found, prepare, dispatch, and send stages with a `RequestContext`, and `ConsoleAppAdapter::start()` runs a `ConsolePipeline` of the register core commands, register app commands, and run stages with a `ConsoleContext`; the run stage rejects a missing or unknown command before running anything, and registration goes through the new `Quantum\Console\CommandRegistrar`. Behavior is unchanged, and runtime stage events are not dispatched yet. - Moved the web adapter, context, and pipeline to `Quantum\App\Adapters\Web`, the console ones to `Quantum\App\Adapters\Console`, and the boot stages to `Quantum\App\Stages\Boot`. Code that goes through `AppFactory` is unaffected; code that references these classes directly must update its imports. - Removed the `AppAdapter` base class and the `WebAppTrait` and `ConsoleAppTrait` traits. `WebAppAdapter` and `ConsoleAppAdapter` now implement `AppInterface` directly, and `ConsoleAppAdapter::createApplication()` stays public. - **BREAKING:** Replaced the `Quantum\Hook` package with `Quantum\Event` (#536):