From 69f7cd12d284f835037f62a696703a217d69246a Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:35:29 +0400 Subject: [PATCH 1/9] [#576] Stop making the environment mutable for every console command The console adapter marked the environment mutable after every boot, which the web adapter does not do. The only commands that write to the env file, `core:key` and the toolkit installer, already make it mutable themselves, so the step is removed. A command outside the framework that calls `environment()->updateRow()` now has to make the environment mutable first. Replaced on purpose: the adapter test that expected a mutable environment now expects the opposite. AppTestCase makes the environment mutable for every test, so the two command tests now set it back to not mutable first, which lets them prove that the commands do not depend on the adapter. Co-Authored-By: Claude Sonnet 5.5 --- .../Adapters/Console/ConsoleAppAdapter.php | 2 -- .../Console/ConsoleAppAdapterTest.php | 4 ++-- .../Commands/InstallToolkitCommandTest.php | 3 +++ .../Commands/KeyGenerateCommandTest.php | 21 +++++++++++++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/App/Adapters/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index ed9153ae3..2de6b6905 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -68,8 +68,6 @@ public function __construct(AppContext $context) $pipeline->run($this->context); - environment()->setMutable(true); - $this->application->setName(config()->get('app.name') ?? self::UNKNOWN); $this->application->setVersion(config()->get('app.version') ?? self::UNKNOWN); } catch (Throwable $exception) { diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index 8c568de5a..7e1ccf4c8 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -295,7 +295,7 @@ public function testConsoleAppAdapterBootsAllStagesForUnknownCommand(): void $this->assertTrue(config()->has('app')); } - public function testConsoleAppAdapterMakesEnvironmentMutableForRegularCommands(): void + public function testConsoleAppAdapterDoesNotMakeTheEnvironmentMutableAfterTheFullBoot(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; @@ -304,7 +304,7 @@ public function testConsoleAppAdapterMakesEnvironmentMutableForRegularCommands() $property = new ReflectionProperty(Environment::class, 'isMutable'); $property->setAccessible(true); - $this->assertTrue($property->getValue(environment())); + $this->assertFalse($property->getValue(environment())); } public function testConsoleAppAdapterNamesTheApplicationFromTheAppConfigAfterTheFullBoot(): void diff --git a/tests/Unit/Console/Commands/InstallToolkitCommandTest.php b/tests/Unit/Console/Commands/InstallToolkitCommandTest.php index 08fdbb755..5ecc12441 100644 --- a/tests/Unit/Console/Commands/InstallToolkitCommandTest.php +++ b/tests/Unit/Console/Commands/InstallToolkitCommandTest.php @@ -58,6 +58,9 @@ protected function runExternalCommand(string $commandName, array $arguments): vo } }; + // AppTestCase makes the environment mutable; the command must not depend on that + environment()->setMutable(false); + $tester = new CommandTester($command); $tester->execute([ 'username' => 'test-user', diff --git a/tests/Unit/Console/Commands/KeyGenerateCommandTest.php b/tests/Unit/Console/Commands/KeyGenerateCommandTest.php index 3c960e1bc..90eaa61ef 100644 --- a/tests/Unit/Console/Commands/KeyGenerateCommandTest.php +++ b/tests/Unit/Console/Commands/KeyGenerateCommandTest.php @@ -6,7 +6,9 @@ use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Helper\HelperSet; use Quantum\Console\Commands\KeyGenerateCommand; +use Quantum\Environment\Environment; use Quantum\Tests\Unit\AppTestCase; +use ReflectionProperty; use Quantum\App\App; class KeyGenerateCommandTest extends AppTestCase @@ -67,6 +69,25 @@ public function testExecGeneratesKey(): void $this->assertNotEmpty(env('APP_KEY')); } + public function testExecStoresANewKeyWithoutTheEnvironmentBeingMutableBeforehand(): void + { + // AppTestCase makes the environment mutable; the command must not depend on that + environment()->setMutable(false); + + $isMutable = new ReflectionProperty(Environment::class, 'isMutable'); + $isMutable->setAccessible(true); + + $this->assertFalse($isMutable->getValue(environment())); + + $originalKey = env('APP_KEY'); + + $this->tester->execute(['--yes' => true, '--length' => 16]); + + $this->assertNotSame($originalKey, env('APP_KEY')); + $this->assertSame(32, strlen((string) env('APP_KEY'))); + $this->assertStringContainsString('APP_KEY=' . env('APP_KEY'), (string) $this->fs->get($this->envFilePath)); + } + public function testExecCancelsWhenNotConfirmed(): void { env('APP_KEY', 'existing-key'); From e97893f047221bc9558ce31db3fa17725b74f670 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:55:43 +0400 Subject: [PATCH 2/9] [#576] Add the boot stage that provides the console application, input and output InitConsoleStage registers the Symfony Application and the console output in the container, the way InitHttpStage registers Request and Response, and sets the command line input as an instance, because the container would autowire the ?array $argv parameter as an empty array and lose the command line. It declares boot.console.before and boot.console.after. The stage is not part of any pipeline yet; the console adapter starts using it in a later commit. Co-Authored-By: Claude Sonnet 5.5 --- src/App/Stages/Boot/InitConsoleStage.php | 47 ++++++++++ tests/Unit/App/BootPipelineTest.php | 4 + .../App/Stages/Boot/InitConsoleStageTest.php | 92 +++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 src/App/Stages/Boot/InitConsoleStage.php create mode 100644 tests/Unit/App/Stages/Boot/InitConsoleStageTest.php diff --git a/src/App/Stages/Boot/InitConsoleStage.php b/src/App/Stages/Boot/InitConsoleStage.php new file mode 100644 index 000000000..be0f1cacf --- /dev/null +++ b/src/App/Stages/Boot/InitConsoleStage.php @@ -0,0 +1,47 @@ +assertSame('boot.http.before', InitHttpStage::BEFORE); $this->assertSame('boot.http.after', InitHttpStage::AFTER); + $this->assertSame('boot.console.before', InitConsoleStage::BEFORE); + $this->assertSame('boot.console.after', InitConsoleStage::AFTER); + $this->assertSame('boot.modules.before', LoadModulesStage::BEFORE); $this->assertSame('boot.modules.after', LoadModulesStage::AFTER); } diff --git a/tests/Unit/App/Stages/Boot/InitConsoleStageTest.php b/tests/Unit/App/Stages/Boot/InitConsoleStageTest.php new file mode 100644 index 000000000..9e86981a7 --- /dev/null +++ b/tests/Unit/App/Stages/Boot/InitConsoleStageTest.php @@ -0,0 +1,92 @@ +context = $this->createContext(); + } + + public function tearDown(): void + { + $this->clearAppContext(); + } + + public function testInitConsoleStageRegistersTheApplicationNamedUnknown(): void + { + (new InitConsoleStage())->process($this->context); + + $application = Di::get(Application::class); + + $this->assertInstanceOf(Application::class, $application); + $this->assertSame('UNKNOWN', $application->getName()); + $this->assertSame('UNKNOWN', $application->getVersion()); + } + + public function testInitConsoleStageRegistersTheApplicationThatTakesTheNameGivenOnTheFirstFetch(): void + { + (new InitConsoleStage())->process($this->context); + + $application = Di::get(Application::class, ['Qt Console', '1.2.3']); + + $this->assertSame('Qt Console', $application->getName()); + $this->assertSame('1.2.3', $application->getVersion()); + $this->assertSame($application, Di::get(Application::class)); + } + + public function testInitConsoleStageRegistersTheConsoleOutput(): void + { + (new InitConsoleStage())->process($this->context); + + $this->assertInstanceOf(ConsoleOutput::class, Di::get(OutputInterface::class)); + } + + public function testInitConsoleStageRegistersAnInputThatReadsTheCommandLine(): void + { + $argv = $_SERVER['argv'] ?? null; + + $_SERVER['argv'] = ['qt', 'core:env', '--yes']; + + try { + (new InitConsoleStage())->process($this->context); + + $input = Di::get(InputInterface::class); + + $this->assertInstanceOf(ArgvInput::class, $input); + $this->assertSame('core:env', $input->getFirstArgument()); + $this->assertTrue($input->hasParameterOption('--yes')); + } finally { + $_SERVER['argv'] = $argv; + } + } + + public function testInitConsoleStageKeepsTheObjectsThatAreAlreadyRegistered(): void + { + $application = new Application('Test', '0.1'); + $input = new ArrayInput([]); + $output = new BufferedOutput(); + + Di::set(Application::class, $application); + Di::set(InputInterface::class, $input); + Di::set(OutputInterface::class, $output); + + (new InitConsoleStage())->process($this->context); + + $this->assertSame($application, Di::get(Application::class)); + $this->assertSame($input, Di::get(InputInterface::class)); + $this->assertSame($output, Di::get(OutputInterface::class)); + } +} From 60bddf80752745814b9233cc333331ca28a50c25 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:53:42 +0400 Subject: [PATCH 3/9] [#576] Make the console adapter constructor a boot pipeline The constructor now only builds the boot stage list and runs it inside the try that records a boot failure, like WebAppAdapter does, with InitConsoleStage right after the helpers. The Symfony Application, the input and the output are no longer adapter properties and createApplication() is gone: start() reads them from the container, names the application from the app config at its first fetch, and builds the context inside the same try as the pipeline, so a failed helpers stage still surfaces as the original failure. The adapter tests no longer stub createApplication(). They register a non-exiting test Application in the container, or let the container build the new NonExitingApplication test helper, and keep every assertion; the ones that read the application now read it from the container. New tests cover the objects in the container after a successful and a failed boot and a helper file that cannot be loaded. Co-Authored-By: Claude Sonnet 5.5 --- .../Adapters/Console/ConsoleAppAdapter.php | 39 ++-- tests/Helpers/NonExitingApplication.php | 20 ++ .../Console/ConsoleAppAdapterTest.php | 220 +++++++++--------- 3 files changed, 148 insertions(+), 131 deletions(-) create mode 100644 tests/Helpers/NonExitingApplication.php diff --git a/src/App/Adapters/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index 2de6b6905..ec9334933 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -12,18 +12,20 @@ use Quantum\App\Stages\Console\RegisterCoreCommandsStage; use Quantum\App\Stages\Console\RegisterAppCommandsStage; -use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Output\OutputInterface; use Quantum\App\Stages\Boot\SetupErrorHandlerStage; +use Symfony\Component\Console\Input\InputInterface; use Quantum\App\Stages\Boot\LoadEnvironmentStage; use Quantum\App\Stages\Console\RunCommandStage; -use Symfony\Component\Console\Input\ArgvInput; use Quantum\App\Stages\Boot\LoadAppConfigStage; +use Quantum\App\Stages\Boot\InitConsoleStage; use Quantum\App\Stages\Boot\LoadHelpersStage; use Symfony\Component\Console\Application; use Quantum\App\Contracts\AppInterface; use Quantum\App\Enums\ExitCode; use Quantum\App\BootPipeline; use Quantum\App\AppContext; +use Quantum\Di\Di; use Throwable; use Exception; @@ -41,52 +43,32 @@ class ConsoleAppAdapter implements AppInterface protected AppContext $context; - protected ArgvInput $input; - - protected ConsoleOutput $output; - - protected Application $application; - protected ?Throwable $bootFailure = null; public function __construct(AppContext $context) { $this->context = $context; - $this->input = new ArgvInput(); - $this->output = new ConsoleOutput(); - - $this->application = $this->createApplication(self::UNKNOWN, self::UNKNOWN); - try { $pipeline = new BootPipeline([ new LoadHelpersStage(), + new InitConsoleStage(), new LoadEnvironmentStage(), new LoadAppConfigStage(), new SetupErrorHandlerStage(), ]); $pipeline->run($this->context); - - $this->application->setName(config()->get('app.name') ?? self::UNKNOWN); - $this->application->setVersion(config()->get('app.version') ?? self::UNKNOWN); } catch (Throwable $exception) { $this->bootFailure = $exception; } } - public function createApplication(string $name, string $version): Application - { - return new Application($name, $version); - } - /** * @throws Exception|Throwable */ public function start(): ?int { - $context = new ConsoleContext($this->context, $this->application, $this->input, $this->output, $this->bootFailure); - $pipeline = new ConsolePipeline([ new RegisterCoreCommandsStage(), new RegisterAppCommandsStage(), @@ -94,6 +76,17 @@ public function start(): ?int ]); try { + $context = new ConsoleContext( + $this->context, + Di::get(Application::class, [ + config()->get('app.name') ?? self::UNKNOWN, + config()->get('app.version') ?? self::UNKNOWN, + ]), + Di::get(InputInterface::class), + Di::get(OutputInterface::class), + $this->bootFailure + ); + $pipeline->run($context); } catch (Throwable $exception) { throw $this->bootFailure ?? $exception; diff --git a/tests/Helpers/NonExitingApplication.php b/tests/Helpers/NonExitingApplication.php new file mode 100644 index 000000000..8f0230c4a --- /dev/null +++ b/tests/Helpers/NonExitingApplication.php @@ -0,0 +1,20 @@ +setAutoExit(false); + $this->setCatchExceptions(false); + } +} diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index 7e1ccf4c8..6ea14ca2e 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -2,8 +2,12 @@ namespace Quantum\Tests\Unit\App\Adapters\Console; +use Symfony\Component\Console\Output\OutputInterface; use Quantum\App\Adapters\Console\ConsoleAppAdapter; +use Symfony\Component\Console\Input\InputInterface; use Quantum\Environment\Exceptions\EnvException; +use Quantum\Tests\Helpers\NonExitingApplication; +use Symfony\Component\Console\Input\ArgvInput; use Quantum\App\Stages\Boot\LoadHelpersStage; use Symfony\Component\Console\Application; use Dotenv\Exception\InvalidFileException; @@ -11,30 +15,20 @@ use Quantum\Tests\Unit\AppTestCase; use Quantum\Tracer\ErrorHandler; use Quantum\App\BootPipeline; +use Quantum\App\AppContext; use Quantum\Di\Di; use ReflectionProperty; use RuntimeException; use Throwable; +use ParseError; use Exception; use Mockery; class ConsoleAppAdapterTest extends AppTestCase { - private $consoleAppAdapter; - public function setUp(): void { - $applicationMock = Mockery::mock(Application::class)->makePartial(); - $applicationMock->shouldReceive('getName')->andReturn('Qt Console Application'); - $applicationMock->shouldReceive('run')->andReturn(0); - - $this->consoleAppAdapter = Mockery::mock(ConsoleAppAdapter::class) - ->shouldAllowMockingProtectedMethods() - ->makePartial(); - - $this->consoleAppAdapter - ->shouldReceive('createApplication') - ->andReturn($applicationMock); + // AppTestCase::setUp() boots a web app, which these tests must not have } public function tearDown(): void @@ -47,9 +41,9 @@ public function testConsoleAppAdapterStartSuccessfully(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; - $this->consoleAppAdapter->__construct($this->createContext()); + $adapter = $this->createAdapter(); - $result = $this->consoleAppAdapter->start(); + $result = $adapter->start(); $this->assertEquals(0, $result); } @@ -62,17 +56,9 @@ public function testConsoleAppAdapterReturnsNonZeroExitCode(): void $applicationMock->shouldReceive('getName')->andReturn('Qt Console Application'); $applicationMock->shouldReceive('run')->andReturn(2); - $consoleAppAdapter = Mockery::mock(ConsoleAppAdapter::class) - ->shouldAllowMockingProtectedMethods() - ->makePartial(); + $adapter = $this->createAdapter($applicationMock); - $consoleAppAdapter - ->shouldReceive('createApplication') - ->andReturn($applicationMock); - - $consoleAppAdapter->__construct($this->createContext()); - - $result = $consoleAppAdapter->start(); + $result = $adapter->start(); $this->assertEquals(2, $result); } @@ -81,23 +67,23 @@ public function testConsoleAppAdapterStartFails(): void { $_SERVER['argv'] = ['qt', 'unknown', '--quiet']; - $this->consoleAppAdapter->__construct($this->createContext()); + $adapter = $this->createAdapter(); $this->expectException(Exception::class); - $this->consoleAppAdapter->start(); + $adapter->start(); } public function testConsoleAppAdapterStartFailsWithoutCommandName(): void { $_SERVER['argv'] = ['qt']; - $this->consoleAppAdapter->__construct($this->createContext()); + $adapter = $this->createAdapter(); $this->expectException(Exception::class); $this->expectExceptionMessage('Command `` is not defined'); - $this->consoleAppAdapter->start(); + $adapter->start(); } public function testConsoleAppAdapterStartRegistersCoreCommands(): void @@ -127,20 +113,11 @@ public function testConsoleAppAdapterStartRegistersCoreCommandsBeforeAppCommands $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); + $application = new NonExitingApplication('Qt Console Application', 'test'); $_SERVER['argv'] = ['qt', 'list', '--quiet']; - $adapter->__construct($this->createContext()); - $adapter->start(); + $this->createAdapter($application)->start(); $names = array_keys($application->all()); @@ -201,7 +178,7 @@ public function testConsoleAppAdapterBootsAllStagesForRegularCommands(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; - $this->consoleAppAdapter->__construct($this->createContext()); + $this->createAdapter(); $this->assertTrue(Di::has(Environment::class)); $this->assertTrue(Di::isRegistered(ErrorHandler::class)); @@ -212,20 +189,46 @@ public function testConsoleAppAdapterBootsAllStagesForCoreEnvCommandWhenTheEnvFi { $_SERVER['argv'] = ['qt', 'core:env']; - $this->consoleAppAdapter->__construct($this->createContext()); + $this->createAdapter(); $this->assertTrue(Di::has(Environment::class)); $this->assertTrue(Di::isRegistered(ErrorHandler::class)); $this->assertTrue(config()->has('app')); } + public function testConsoleAppAdapterRegistersTheConsoleObjectsInTheContainerAfterASuccessfulBoot(): void + { + $_SERVER['argv'] = ['qt', 'core:env', '--yes']; + + new ConsoleAppAdapter($this->createContext()); + + $this->assertTrue(Di::isRegistered(Application::class)); + $this->assertTrue(Di::isRegistered(OutputInterface::class)); + $this->assertInstanceOf(ArgvInput::class, Di::get(InputInterface::class)); + $this->assertSame('core:env', Di::get(InputInterface::class)->getFirstArgument()); + } + + public function testConsoleAppAdapterRegistersTheConsoleObjectsInTheContainerAfterAFailedBoot(): void + { + $_SERVER['argv'] = ['qt', 'core:env', '--yes']; + + $this->withoutEnvironmentFile(function (): void { + new ConsoleAppAdapter($this->createContext()); + + $this->assertTrue(Di::isRegistered(Application::class)); + $this->assertTrue(Di::isRegistered(OutputInterface::class)); + $this->assertInstanceOf(ArgvInput::class, Di::get(InputInterface::class)); + $this->assertSame('core:env', Di::get(InputInterface::class)->getFirstArgument()); + }); + } + public function testConsoleAppAdapterRecordsNoBootFailureAfterASuccessfulBoot(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; - $this->consoleAppAdapter->__construct($this->createContext()); + $adapter = $this->createAdapter(); - $this->assertNull($this->bootFailureOf($this->consoleAppAdapter)); + $this->assertNull($this->bootFailureOf($adapter)); } public function testConsoleAppAdapterRecordsTheBootFailureInsteadOfThrowingWhenTheEnvFileIsMissing(): void @@ -233,9 +236,9 @@ public function testConsoleAppAdapterRecordsTheBootFailureInsteadOfThrowingWhenT $_SERVER['argv'] = ['qt', 'list', '--quiet']; $this->withoutEnvironmentFile(function (): void { - $this->consoleAppAdapter->__construct($this->createContext()); + $adapter = $this->createAdapter(); - $this->assertInstanceOf(EnvException::class, $this->bootFailureOf($this->consoleAppAdapter)); + $this->assertInstanceOf(EnvException::class, $this->bootFailureOf($adapter)); }); } @@ -244,7 +247,7 @@ public function testConsoleAppAdapterDoesNotMakeTheEnvironmentMutableAfterAFaile $_SERVER['argv'] = ['qt', 'list', '--quiet']; $this->withoutEnvironmentFile(function (): void { - $this->consoleAppAdapter->__construct($this->createContext()); + $this->createAdapter(); $this->assertFalse(Di::has(Environment::class)); }); @@ -260,11 +263,12 @@ public function testConsoleAppAdapterNamesTheApplicationUnknownWhenTheAppNameIsE config()->set('app', ['name' => null, 'version' => null]); - $adapter = new ConsoleAppAdapter($context); + $adapter = $this->createAdapter(null, $context); + $adapter->start(); $this->assertNull($this->bootFailureOf($adapter)); - $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getName()); - $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getVersion()); + $this->assertSame('UNKNOWN', Di::get(Application::class)->getName()); + $this->assertSame('UNKNOWN', Di::get(Application::class)->getVersion()); } public function testConsoleAppAdapterKeepsAnEmptyAppNameAndVersionFromTheConfig(): void @@ -277,18 +281,19 @@ public function testConsoleAppAdapterKeepsAnEmptyAppNameAndVersionFromTheConfig( config()->set('app', ['name' => '', 'version' => '0']); - $adapter = new ConsoleAppAdapter($context); + $adapter = $this->createAdapter(null, $context); + $adapter->start(); $this->assertNull($this->bootFailureOf($adapter)); - $this->assertSame('', $this->applicationOf($adapter)->getName()); - $this->assertSame('0', $this->applicationOf($adapter)->getVersion()); + $this->assertSame('', Di::get(Application::class)->getName()); + $this->assertSame('0', Di::get(Application::class)->getVersion()); } public function testConsoleAppAdapterBootsAllStagesForUnknownCommand(): void { $_SERVER['argv'] = ['qt', 'unknown:command', '--quiet']; - $this->consoleAppAdapter->__construct($this->createContext()); + $this->createAdapter(); $this->assertTrue(Di::has(Environment::class)); $this->assertTrue(Di::isRegistered(ErrorHandler::class)); @@ -299,7 +304,7 @@ public function testConsoleAppAdapterDoesNotMakeTheEnvironmentMutableAfterTheFul { $_SERVER['argv'] = ['qt', 'list', '--quiet']; - $this->consoleAppAdapter->__construct($this->createContext()); + $this->createAdapter(); $property = new ReflectionProperty(Environment::class, 'isMutable'); $property->setAccessible(true); @@ -311,22 +316,22 @@ public function testConsoleAppAdapterNamesTheApplicationFromTheAppConfigAfterThe { $_SERVER['argv'] = ['qt', 'list', '--quiet']; - $adapter = new ConsoleAppAdapter($this->createContext()); + $this->createAdapter()->start(); - $this->assertSame(config()->get('app.name'), $this->applicationOf($adapter)->getName()); - $this->assertSame(config()->get('app.version'), $this->applicationOf($adapter)->getVersion()); - $this->assertNotSame('UNKNOWN', $this->applicationOf($adapter)->getName()); + $this->assertSame(config()->get('app.name'), Di::get(Application::class)->getName()); + $this->assertSame(config()->get('app.version'), Di::get(Application::class)->getVersion()); + $this->assertNotSame('UNKNOWN', Di::get(Application::class)->getName()); } public function testConsoleAppAdapterNamesTheApplicationUnknownAfterAFailedBoot(): void { - $_SERVER['argv'] = ['qt', 'core:env']; + $_SERVER['argv'] = ['qt', 'list', '--quiet']; $this->withoutEnvironmentFile(function (): void { - $adapter = new ConsoleAppAdapter($this->createContext()); + $this->createAdapter()->start(); - $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getName()); - $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getVersion()); + $this->assertSame('UNKNOWN', Di::get(Application::class)->getName()); + $this->assertSame('UNKNOWN', Di::get(Application::class)->getVersion()); }); } @@ -338,11 +343,11 @@ public function testConsoleAppAdapterStartRejectsACommandThatRequiresTheEnvironm $_SERVER['argv'] = ['qt', $commandName, '--quiet']; $this->withoutEnvironmentFile(function (): void { - $this->consoleAppAdapter->__construct($this->createContext()); + $adapter = $this->createAdapter(); $this->expectException(EnvException::class); - $this->consoleAppAdapter->start(); + $adapter->start(); }); }); } @@ -470,6 +475,25 @@ public function testConsoleAppAdapterStartReportsTheCommandFailureWhenACommandCa }); } + public function testConsoleAppAdapterStartReportsTheBootFailureWhenAHelperFileCannotBeLoaded(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $file = PROJECT_ROOT . DS . 'helpers' . DS . 'tmp_broken_helper_' . substr(md5(uniqid('', true)), 0, 8) . '.php'; + + $this->assertNotFalse(file_put_contents($file, "createAdapter(); + + $this->expectException(ParseError::class); + + $adapter->start(); + } finally { + unlink($file); + } + } + /** * Replaces the content of the environment file of the test app for the duration of the callback */ @@ -494,20 +518,30 @@ private function withEnvironmentFileContent(string $content, callable $test): vo } /** - * Boots the real adapter, with its own createApplication(), for the command and keeps - * the Symfony Application from exiting and from catching exceptions, so start() can be asserted on + * Creates the adapter on a fresh context (or the given one) with a test Application in the container: + * the given one, or one that does not exit and is built, and named, when start() first fetches it */ - private function bootRealAdapter(string $commandName): ConsoleAppAdapter + private function createAdapter(?Application $application = null, ?AppContext $context = null): ConsoleAppAdapter { - $_SERVER['argv'] = ['qt', $commandName, '--quiet']; + $context ??= $this->createContext(); - $adapter = new ConsoleAppAdapter($this->createContext()); + if ($application !== null) { + Di::set(Application::class, $application); + } else { + Di::register(NonExitingApplication::class, Application::class); + } - $application = $this->applicationOf($adapter); - $application->setAutoExit(false); - $application->setCatchExceptions(false); + return new ConsoleAppAdapter($context); + } - return $adapter; + /** + * Boots the adapter for the command, so start() can be asserted on + */ + private function bootRealAdapter(string $commandName): ConsoleAppAdapter + { + $_SERVER['argv'] = ['qt', $commandName, '--quiet']; + + return $this->createAdapter(); } /** @@ -571,38 +605,17 @@ private function bootFailureOf(ConsoleAppAdapter $adapter): ?Throwable return $property->getValue($adapter); } - /** - * The Symfony Application the adapter built while booting - */ - private function applicationOf(ConsoleAppAdapter $adapter): Application - { - $property = new ReflectionProperty(ConsoleAppAdapter::class, 'application'); - $property->setAccessible(true); - - return $property->getValue($adapter); - } - /** * 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); + $application = new NonExitingApplication('Qt Console Application', 'test'); $_SERVER['argv'] = ['qt', $commandName, '--quiet']; - $adapter->__construct($this->createContext()); - - return $adapter->start(); + return $this->createAdapter($application)->start(); } /** @@ -611,20 +624,11 @@ private function startWithRealApplication(string $commandName): ?int */ 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); + $application = new NonExitingApplication('Qt Console Application', 'test'); $_SERVER['argv'] = ['qt', 'list', '--quiet']; - $adapter->__construct($this->createContext()); - $adapter->start(); + $this->createAdapter($application)->start(); return array_keys($application->all()); } From 992554c510042cd30c95776891f489396272d85e Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:27:00 +0400 Subject: [PATCH 4/9] [#576] Let the console context read its objects from the container The context owns the container through the AppContext, so the console application, input and output no longer have to be passed in. ConsoleContext is built with the AppContext and the boot failure only, and its getters read the objects from the container when asked. start() no longer touches Di or the config; it defines the pipeline, builds the context and runs it. The application is named by a new boot stage right after the config is loaded. After a failed boot that stage is not reached and the application keeps Symfony's UNKNOWN default, as before. ConsoleContext::getAppContext() is removed because nothing called it. Replaced on purpose: the ConsoleContext test that asserted getAppContext(). The test helper that builds a console context now registers the chosen Application, input and output in the container; no other assertion changed. Co-Authored-By: Claude Sonnet 5.5 --- .../Adapters/Console/ConsoleAppAdapter.php | 21 +----- src/App/Adapters/Console/ConsoleContext.php | 30 ++------ .../Boot/SetupConsoleApplicationStage.php | 31 ++++++++ .../Adapters/Console/ConsoleContextTest.php | 29 ++++++-- .../Adapters/Console/ConsolePipelineTest.php | 10 +-- tests/Unit/App/BootPipelineTest.php | 5 +- .../Boot/SetupConsoleApplicationStageTest.php | 74 +++++++++++++++++++ .../Stages/Console/ConsoleStageTestCase.php | 9 ++- 8 files changed, 148 insertions(+), 61 deletions(-) create mode 100644 src/App/Stages/Boot/SetupConsoleApplicationStage.php create mode 100644 tests/Unit/App/Stages/Boot/SetupConsoleApplicationStageTest.php diff --git a/src/App/Adapters/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index ec9334933..488a14b0a 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -10,22 +10,19 @@ namespace Quantum\App\Adapters\Console; +use Quantum\App\Stages\Boot\SetupConsoleApplicationStage; use Quantum\App\Stages\Console\RegisterCoreCommandsStage; use Quantum\App\Stages\Console\RegisterAppCommandsStage; -use Symfony\Component\Console\Output\OutputInterface; use Quantum\App\Stages\Boot\SetupErrorHandlerStage; -use Symfony\Component\Console\Input\InputInterface; use Quantum\App\Stages\Boot\LoadEnvironmentStage; use Quantum\App\Stages\Console\RunCommandStage; use Quantum\App\Stages\Boot\LoadAppConfigStage; use Quantum\App\Stages\Boot\InitConsoleStage; use Quantum\App\Stages\Boot\LoadHelpersStage; -use Symfony\Component\Console\Application; use Quantum\App\Contracts\AppInterface; use Quantum\App\Enums\ExitCode; use Quantum\App\BootPipeline; use Quantum\App\AppContext; -use Quantum\Di\Di; use Throwable; use Exception; @@ -39,8 +36,6 @@ */ class ConsoleAppAdapter implements AppInterface { - private const UNKNOWN = 'UNKNOWN'; - protected AppContext $context; protected ?Throwable $bootFailure = null; @@ -55,6 +50,7 @@ public function __construct(AppContext $context) new InitConsoleStage(), new LoadEnvironmentStage(), new LoadAppConfigStage(), + new SetupConsoleApplicationStage(), new SetupErrorHandlerStage(), ]); @@ -75,18 +71,9 @@ public function start(): ?int new RunCommandStage(), ]); - try { - $context = new ConsoleContext( - $this->context, - Di::get(Application::class, [ - config()->get('app.name') ?? self::UNKNOWN, - config()->get('app.version') ?? self::UNKNOWN, - ]), - Di::get(InputInterface::class), - Di::get(OutputInterface::class), - $this->bootFailure - ); + $context = new ConsoleContext($this->context, $this->bootFailure); + try { $pipeline->run($context); } catch (Throwable $exception) { throw $this->bootFailure ?? $exception; diff --git a/src/App/Adapters/Console/ConsoleContext.php b/src/App/Adapters/Console/ConsoleContext.php index 469624bb2..6a0b4faaa 100644 --- a/src/App/Adapters/Console/ConsoleContext.php +++ b/src/App/Adapters/Console/ConsoleContext.php @@ -22,6 +22,7 @@ * * Carries the state of a single console run through the console stages. * Unlike AppContext, which is immutable, the exit code is set by the stages. + * The Symfony application, input and output are read from the container of the AppContext. * * @package Quantum\App */ @@ -29,48 +30,29 @@ class ConsoleContext { private AppContext $appContext; - private Application $application; - - private InputInterface $input; - - private OutputInterface $output; - private ?Throwable $bootFailure; private int $exitCode = ExitCode::SUCCESS; - public function __construct( - AppContext $appContext, - Application $application, - InputInterface $input, - OutputInterface $output, - ?Throwable $bootFailure = null - ) { + public function __construct(AppContext $appContext, ?Throwable $bootFailure = null) + { $this->appContext = $appContext; - $this->application = $application; - $this->input = $input; - $this->output = $output; $this->bootFailure = $bootFailure; } - public function getAppContext(): AppContext - { - return $this->appContext; - } - public function getApplication(): Application { - return $this->application; + return $this->appContext->getContainer()->get(Application::class); } public function getInput(): InputInterface { - return $this->input; + return $this->appContext->getContainer()->get(InputInterface::class); } public function getOutput(): OutputInterface { - return $this->output; + return $this->appContext->getContainer()->get(OutputInterface::class); } /** diff --git a/src/App/Stages/Boot/SetupConsoleApplicationStage.php b/src/App/Stages/Boot/SetupConsoleApplicationStage.php new file mode 100644 index 000000000..3a95115b9 --- /dev/null +++ b/src/App/Stages/Boot/SetupConsoleApplicationStage.php @@ -0,0 +1,31 @@ +setName(config()->get('app.name') ?? 'UNKNOWN'); + $application->setVersion(config()->get('app.version') ?? 'UNKNOWN'); + } +} diff --git a/tests/Unit/App/Adapters/Console/ConsoleContextTest.php b/tests/Unit/App/Adapters/Console/ConsoleContextTest.php index e756aeb0f..c549c1561 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleContextTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleContextTest.php @@ -2,15 +2,18 @@ namespace Quantum\Tests\Unit\App\Adapters\Console; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Application; use Quantum\Tests\Unit\AppTestCase; use Quantum\App\Adapters\Console\ConsoleContext; use Quantum\App\Enums\ExitCode; use Quantum\App\AppContext; -use Quantum\App\App; use RuntimeException; +use Quantum\App\App; +use Quantum\Di\Di; class ConsoleContextTest extends AppTestCase { @@ -33,21 +36,31 @@ public function setUp(): void $this->input = new ArrayInput([]); $this->output = new BufferedOutput(); - $this->consoleContext = new ConsoleContext($this->appContext, $this->application, $this->input, $this->output); - } + Di::set(Application::class, $this->application); + Di::set(InputInterface::class, $this->input); + Di::set(OutputInterface::class, $this->output); - public function testConsoleContextExposesAppContext(): void - { - $this->assertSame($this->appContext, $this->consoleContext->getAppContext()); + $this->consoleContext = new ConsoleContext($this->appContext); } - public function testConsoleContextExposesApplicationInputAndOutput(): void + public function testConsoleContextExposesApplicationInputAndOutputFromTheContainer(): void { $this->assertSame($this->application, $this->consoleContext->getApplication()); $this->assertSame($this->input, $this->consoleContext->getInput()); $this->assertSame($this->output, $this->consoleContext->getOutput()); } + public function testConsoleContextReadsTheConsoleObjectsFromTheContainerWhenAsked(): void + { + $context = new AppContext(PROJECT_ROOT, new \Quantum\Di\DiContainer()); + $consoleContext = new ConsoleContext($context); + + $application = new Application('Late', '1.0'); + $context->getContainer()->set(Application::class, $application); + + $this->assertSame($application, $consoleContext->getApplication()); + } + public function testConsoleContextHasNoBootFailureByDefault(): void { $this->assertNull($this->consoleContext->getBootFailure()); @@ -57,7 +70,7 @@ public function testConsoleContextExposesTheBootFailure(): void { $failure = new RuntimeException('The boot failed'); - $consoleContext = new ConsoleContext($this->appContext, $this->application, $this->input, $this->output, $failure); + $consoleContext = new ConsoleContext($this->appContext, $failure); $this->assertSame($failure, $consoleContext->getBootFailure()); } diff --git a/tests/Unit/App/Adapters/Console/ConsolePipelineTest.php b/tests/Unit/App/Adapters/Console/ConsolePipelineTest.php index 2eda00e96..caee0e559 100644 --- a/tests/Unit/App/Adapters/Console/ConsolePipelineTest.php +++ b/tests/Unit/App/Adapters/Console/ConsolePipelineTest.php @@ -2,12 +2,9 @@ namespace Quantum\Tests\Unit\App\Adapters\Console; -use Symfony\Component\Console\Output\BufferedOutput; use Quantum\App\Contracts\ConsoleStageInterface; use Quantum\App\Contracts\RequestStageInterface; use Quantum\App\Contracts\BootStageInterface; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Application; use Quantum\Tests\Unit\AppTestCase; use Quantum\App\Adapters\Console\ConsoleContext; use Quantum\App\Adapters\Console\ConsolePipeline; @@ -23,12 +20,7 @@ class ConsolePipelineTest extends AppTestCase public function setUp(): void { - $this->consoleContext = new ConsoleContext( - $this->createContext(), - new Application(), - new ArrayInput([]), - new BufferedOutput() - ); + $this->consoleContext = new ConsoleContext($this->createContext()); } public function tearDown(): void diff --git a/tests/Unit/App/BootPipelineTest.php b/tests/Unit/App/BootPipelineTest.php index 9dc7affb4..539c77704 100644 --- a/tests/Unit/App/BootPipelineTest.php +++ b/tests/Unit/App/BootPipelineTest.php @@ -2,6 +2,7 @@ namespace Quantum\Tests\Unit\App; +use Quantum\App\Stages\Boot\SetupConsoleApplicationStage; use Quantum\App\Stages\Boot\SetupErrorHandlerStage; use Quantum\App\Contracts\BootStageInterface; use Quantum\App\Stages\Boot\LoadEnvironmentStage; @@ -227,9 +228,9 @@ public function testPipelineStagesDeclareBootEventNames(): void $this->assertSame('boot.modules.after', LoadModulesStage::AFTER); } - public function testLoadHelpersAndInitDebuggerStagesDeclareNoEvents(): void + public function testLoadHelpersInitDebuggerAndSetupConsoleApplicationStagesDeclareNoEvents(): void { - foreach ([LoadHelpersStage::class, InitDebuggerStage::class] as $stage) { + foreach ([LoadHelpersStage::class, InitDebuggerStage::class, SetupConsoleApplicationStage::class] as $stage) { $this->assertFalse(defined($stage . '::BEFORE'), $stage . ' must not declare BEFORE'); $this->assertFalse(defined($stage . '::AFTER'), $stage . ' must not declare AFTER'); } diff --git a/tests/Unit/App/Stages/Boot/SetupConsoleApplicationStageTest.php b/tests/Unit/App/Stages/Boot/SetupConsoleApplicationStageTest.php new file mode 100644 index 000000000..55d3962c6 --- /dev/null +++ b/tests/Unit/App/Stages/Boot/SetupConsoleApplicationStageTest.php @@ -0,0 +1,74 @@ +context = $this->createContext(); + + (new LoadHelpersStage())->process($this->context); + + Di::register(Application::class); + } + + public function tearDown(): void + { + config()->flush(); + $this->clearAppContext(); + } + + public function testSetupConsoleApplicationStageNamesTheApplicationFromTheConfig(): void + { + config()->set('app', ['name' => 'Qt Console', 'version' => '1.2.3']); + + (new SetupConsoleApplicationStage())->process($this->context); + + $this->assertSame('Qt Console', Di::get(Application::class)->getName()); + $this->assertSame('1.2.3', Di::get(Application::class)->getVersion()); + } + + public function testSetupConsoleApplicationStageNamesTheApplicationUnknownWhenTheConfigHasNoNameOrVersion(): void + { + config()->set('app', ['name' => null, 'version' => null]); + + (new SetupConsoleApplicationStage())->process($this->context); + + $this->assertSame('UNKNOWN', Di::get(Application::class)->getName()); + $this->assertSame('UNKNOWN', Di::get(Application::class)->getVersion()); + } + + public function testSetupConsoleApplicationStageKeepsAnEmptyNameAndVersion(): void + { + config()->set('app', ['name' => '', 'version' => '0']); + + (new SetupConsoleApplicationStage())->process($this->context); + + $this->assertSame('', Di::get(Application::class)->getName()); + $this->assertSame('0', Di::get(Application::class)->getVersion()); + } + + public function testSetupConsoleApplicationStageNamesAnApplicationThatIsAlreadyRegistered(): void + { + $application = new Application('Registered', '0.1'); + + $this->context = $this->createContext(); + (new LoadHelpersStage())->process($this->context); + Di::set(Application::class, $application); + + config()->set('app', ['name' => 'Qt Console', 'version' => '1.2.3']); + + (new SetupConsoleApplicationStage())->process($this->context); + + $this->assertSame($application, Di::get(Application::class)); + $this->assertSame('Qt Console', $application->getName()); + $this->assertSame('1.2.3', $application->getVersion()); + } +} diff --git a/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php b/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php index 410c65636..fd1e3fef3 100644 --- a/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php +++ b/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php @@ -2,12 +2,15 @@ namespace Quantum\Tests\Unit\App\Stages\Console; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Application; use Quantum\Tests\Unit\AppTestCase; use Quantum\App\Adapters\Console\ConsoleContext; use Quantum\App\App; +use Quantum\Di\Di; use Throwable; abstract class ConsoleStageTestCase extends AppTestCase @@ -22,7 +25,11 @@ protected function createConsoleContext(array $input = [], ?Application $applica $application->setAutoExit(false); $application->setCatchExceptions(false); - return new ConsoleContext(App::getContext(), $application, new ArrayInput($input), new BufferedOutput(), $bootFailure); + Di::set(Application::class, $application); + Di::set(InputInterface::class, new ArrayInput($input)); + Di::set(OutputInterface::class, new BufferedOutput()); + + return new ConsoleContext(App::getContext(), $bootFailure); } /** From fe16aa6886f2f4fab8b0fb60bf238e250a025eb0 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:50:48 +0400 Subject: [PATCH 5/9] [#576] Add the changelog entry for the console constructor change Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a41c472a..4cd62e106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ 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:** The console adapter constructor is now only the boot pipeline, like the web adapter (#576): + - The Symfony `Application`, the input and the output are provided by the new `InitConsoleStage` through the container, and the new `SetupConsoleApplicationStage` names the application from `app.name` and `app.version` once the config is loaded (it stays `UNKNOWN` after a failed boot). `ConsoleContext` is built from the `AppContext` and the boot failure, and reads those objects from the container. Removed `ConsoleAppAdapter::createApplication()` and `ConsoleContext::getAppContext()`. + - The adapter no longer makes the environment mutable for every command. `core:key` and the toolkit installer already do it themselves; a command outside the framework that calls `environment()->updateRow()` must call `setMutable(true)` first, or it gets `EnvException::environmentImmutable()`. - The console boot no longer decides by the `core:env` command name (#575): - `ConsoleAppAdapter` always runs the full boot and, when it fails, keeps the failure instead of stopping. `RunCommandStage` re-throws that original failure only for a command that requires the environment, so `core:env`, `list`, and `help` still run and `core:env --yes` can repair a missing, empty, or malformed `.env`. The reduced boot for `core:env` is gone, and the Symfony `Application` is named `UNKNOWN` until a successful boot names it from the app config. - Added `CliCommand::$requiresEnvironment` (default `true`) with `CliCommand::requiresEnvironment()`; a command that works without the environment, like `EnvCommand`, sets it to `false`. `ConsoleContext` takes an optional boot failure and exposes it with `getBootFailure()`. From d1c7fd3fc72a7892ed6907680ad89e4b57da6b79 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:05:35 +0400 Subject: [PATCH 6/9] [#576] Create the helpers folder in the broken helper test when it is missing The test that writes a helper file with a syntax error into the test app helpers folder assumed the folder exists, which it only does locally because other tests leave it behind; a clean checkout has no such folder, so writing the file failed on CI. The test now creates the folder when it is missing and removes it afterwards, like the other tests that write app helpers. Co-Authored-By: Claude Sonnet 5.5 --- .../Console/ConsoleAppAdapterTest.php | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index 6ea14ca2e..761317c1d 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -479,18 +479,30 @@ public function testConsoleAppAdapterStartReportsTheBootFailureWhenAHelperFileCa { $_SERVER['argv'] = ['qt', 'list', '--quiet']; - $file = PROJECT_ROOT . DS . 'helpers' . DS . 'tmp_broken_helper_' . substr(md5(uniqid('', true)), 0, 8) . '.php'; - - $this->assertNotFalse(file_put_contents($file, "assertTrue(mkdir($directory)); + } + + $this->assertNotFalse(file_put_contents($file, "createAdapter(); $this->expectException(ParseError::class); $adapter->start(); } finally { - unlink($file); + if (is_file($file)) { + unlink($file); + } + + if ($createdDirectory && is_dir($directory)) { + rmdir($directory); + } } } From c709e54c5c110ca333b16bf86a2f935335ea971a Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:26:11 +0400 Subject: [PATCH 7/9] [#576] Fix the changelog entries about createApplication() The #537 entry still said that ConsoleAppAdapter::createApplication() stays public, which is no longer true because the #576 entry removes it. The claim is dropped and the #576 entry now says where to get the application instead. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cd62e106..3f167781f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ The format is based on Keep a Changelog. ### Changed - **BREAKING:** The console adapter constructor is now only the boot pipeline, like the web adapter (#576): - - The Symfony `Application`, the input and the output are provided by the new `InitConsoleStage` through the container, and the new `SetupConsoleApplicationStage` names the application from `app.name` and `app.version` once the config is loaded (it stays `UNKNOWN` after a failed boot). `ConsoleContext` is built from the `AppContext` and the boot failure, and reads those objects from the container. Removed `ConsoleAppAdapter::createApplication()` and `ConsoleContext::getAppContext()`. + - The Symfony `Application`, the input and the output are provided by the new `InitConsoleStage` through the container, and the new `SetupConsoleApplicationStage` names the application from `app.name` and `app.version` once the config is loaded (it stays `UNKNOWN` after a failed boot). `ConsoleContext` is built from the `AppContext` and the boot failure, and reads those objects from the container. Removed `ConsoleAppAdapter::createApplication()` (get the application with `Di::get(Application::class)`) and `ConsoleContext::getAppContext()`. - The adapter no longer makes the environment mutable for every command. `core:key` and the toolkit installer already do it themselves; a command outside the framework that calls `environment()->updateRow()` must call `setMutable(true)` first, or it gets `EnvException::environmentImmutable()`. - The console boot no longer decides by the `core:env` command name (#575): - `ConsoleAppAdapter` always runs the full boot and, when it fails, keeps the failure instead of stopping. `RunCommandStage` re-throws that original failure only for a command that requires the environment, so `core:env`, `list`, and `help` still run and `core:env --yes` can repair a missing, empty, or malformed `.env`. The reduced boot for `core:env` is gone, and the Symfony `Application` is named `UNKNOWN` until a successful boot names it from the app config. @@ -20,7 +20,7 @@ The format is based on Keep a Changelog. - **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, 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. + - Removed the `AppAdapter` base class and the `WebAppTrait` and `ConsoleAppTrait` traits. `WebAppAdapter` and `ConsoleAppAdapter` now implement `AppInterface` directly. - **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 20e615fd8d77ade49dfdc13ea0530bf00f04cf40 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:26:21 +0400 Subject: [PATCH 8/9] [#576] Name the console application only after the whole boot succeeded SetupConsoleApplicationStage ran right after the config was loaded, so when the error handler stage failed after it (for example on a broken logging config) the application already had its configured name, while on master it keeps Symfony's UNKNOWN default unless the whole boot succeeds. The stage now runs last, after SetupErrorHandlerStage, and the changelog says so. A new adapter test breaks the logging config and expects the recorded boot failure and the UNKNOWN application; it uses a small helper that replaces the content of any test app file and restores it afterwards, which the environment file helper now delegates to. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- .../Adapters/Console/ConsoleAppAdapter.php | 2 +- .../Console/ConsoleAppAdapterTest.php | 24 ++++++++++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f167781f..25956c6b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ The format is based on Keep a Changelog. ### Changed - **BREAKING:** The console adapter constructor is now only the boot pipeline, like the web adapter (#576): - - The Symfony `Application`, the input and the output are provided by the new `InitConsoleStage` through the container, and the new `SetupConsoleApplicationStage` names the application from `app.name` and `app.version` once the config is loaded (it stays `UNKNOWN` after a failed boot). `ConsoleContext` is built from the `AppContext` and the boot failure, and reads those objects from the container. Removed `ConsoleAppAdapter::createApplication()` (get the application with `Di::get(Application::class)`) and `ConsoleContext::getAppContext()`. + - The Symfony `Application`, the input and the output are provided by the new `InitConsoleStage` through the container, and the new `SetupConsoleApplicationStage` names the application from `app.name` and `app.version` once the whole boot has succeeded (it stays `UNKNOWN` after a failed boot). `ConsoleContext` is built from the `AppContext` and the boot failure, and reads those objects from the container. Removed `ConsoleAppAdapter::createApplication()` (get the application with `Di::get(Application::class)`) and `ConsoleContext::getAppContext()`. - The adapter no longer makes the environment mutable for every command. `core:key` and the toolkit installer already do it themselves; a command outside the framework that calls `environment()->updateRow()` must call `setMutable(true)` first, or it gets `EnvException::environmentImmutable()`. - The console boot no longer decides by the `core:env` command name (#575): - `ConsoleAppAdapter` always runs the full boot and, when it fails, keeps the failure instead of stopping. `RunCommandStage` re-throws that original failure only for a command that requires the environment, so `core:env`, `list`, and `help` still run and `core:env --yes` can repair a missing, empty, or malformed `.env`. The reduced boot for `core:env` is gone, and the Symfony `Application` is named `UNKNOWN` until a successful boot names it from the app config. diff --git a/src/App/Adapters/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index 488a14b0a..fe533c144 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -50,8 +50,8 @@ public function __construct(AppContext $context) new InitConsoleStage(), new LoadEnvironmentStage(), new LoadAppConfigStage(), - new SetupConsoleApplicationStage(), new SetupErrorHandlerStage(), + new SetupConsoleApplicationStage(), ]); $pipeline->run($this->context); diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index 761317c1d..f6d801b31 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -335,6 +335,21 @@ public function testConsoleAppAdapterNamesTheApplicationUnknownAfterAFailedBoot( }); } + public function testConsoleAppAdapterKeepsTheApplicationUnknownWhenTheErrorHandlerCannotBeSetUp(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $this->withFileContent(PROJECT_ROOT . DS . 'shared' . DS . 'config' . DS . 'logging.php', "createAdapter(); + $adapter->start(); + + $this->assertNotNull($this->bootFailureOf($adapter)); + $this->assertTrue(config()->has('app')); + $this->assertSame('UNKNOWN', Di::get(Application::class)->getName()); + $this->assertSame('UNKNOWN', Di::get(Application::class)->getVersion()); + }); + } + public function testConsoleAppAdapterStartRejectsACommandThatRequiresTheEnvironmentWhenTheEnvFileIsMissing(): void { $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; @@ -511,7 +526,14 @@ public function testConsoleAppAdapterStartReportsTheBootFailureWhenAHelperFileCa */ private function withEnvironmentFileContent(string $content, callable $test): void { - $file = PROJECT_ROOT . DS . '.env.testing'; + $this->withFileContent(PROJECT_ROOT . DS . '.env.testing', $content, $test); + } + + /** + * Replaces the content of a file of the test app for the duration of the callback + */ + private function withFileContent(string $file, string $content, callable $test): void + { $backup = $file . '.backup'; $this->assertTrue(rename($file, $backup)); From 30241dee9e9483c76c16cfa4440bed2a10cc65ca Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:27:12 +0400 Subject: [PATCH 9/9] [#576] Create the console application before the helpers are loaded InitConsoleStage ran second, after LoadHelpersStage, so when a project helper file failed to load the application, input and output were never registered and `core:env` and `list` stopped working, while on master the application was built before anything could fail and both still ran. The stage now runs first. The pipeline cannot dispatch stage events before the helpers are loaded, so the boot.console.before and boot.console.after names it declared could never fire; the constants are removed and the stage is listed with the stages that declare no events. Replaced on purpose: the test that expected a ParseError from `list` when a helper file cannot be loaded, which master does not do. Three tests now cover `list` and a command that does not need the environment running, and a command that needs it being rejected with the helper error. Co-Authored-By: Claude Sonnet 5.5 --- .../Adapters/Console/ConsoleAppAdapter.php | 2 +- src/App/Stages/Boot/InitConsoleStage.php | 8 +-- .../Console/ConsoleAppAdapterTest.php | 52 ++++++++++++++++--- tests/Unit/App/BootPipelineTest.php | 7 +-- 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/src/App/Adapters/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index fe533c144..1f68f6463 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -46,8 +46,8 @@ public function __construct(AppContext $context) try { $pipeline = new BootPipeline([ - new LoadHelpersStage(), new InitConsoleStage(), + new LoadHelpersStage(), new LoadEnvironmentStage(), new LoadAppConfigStage(), new SetupErrorHandlerStage(), diff --git a/src/App/Stages/Boot/InitConsoleStage.php b/src/App/Stages/Boot/InitConsoleStage.php index be0f1cacf..ffc508544 100644 --- a/src/App/Stages/Boot/InitConsoleStage.php +++ b/src/App/Stages/Boot/InitConsoleStage.php @@ -21,14 +21,14 @@ /** * Class InitConsoleStage + * + * Runs before the helpers are loaded, so that the console application, input and output exist + * even when a helper file fails; for the same reason it declares no boot events. + * * @package Quantum\App */ class InitConsoleStage implements BootStageInterface { - public const BEFORE = 'boot.console.before'; - - public const AFTER = 'boot.console.after'; - public function process(AppContext $context): void { if (!Di::isRegistered(Application::class)) { diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index f6d801b31..87f2f92f2 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -490,10 +490,54 @@ public function testConsoleAppAdapterStartReportsTheCommandFailureWhenACommandCa }); } - public function testConsoleAppAdapterStartReportsTheBootFailureWhenAHelperFileCannotBeLoaded(): void + public function testConsoleAppAdapterStartRunsListWhenAnAppHelperFileCannotBeLoaded(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; + $this->withBrokenAppHelper(function (): void { + $adapter = $this->createAdapter(); + + $this->assertSame(0, $adapter->start()); + $this->assertInstanceOf(ParseError::class, $this->bootFailureOf($adapter)); + }); + } + + public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvironmentWhenAnAppHelperFileCannotBeLoaded(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $_SERVER['argv'] = ['qt', $commandName, '--quiet']; + + $this->withBrokenAppHelper(function () use ($commandName): void { + $this->assertSame(0, $this->createAdapter()->start()); + $this->assertSame($commandName, $GLOBALS['tmpCommandRan'] ?? null); + }); + }, null, false); + } + + public function testConsoleAppAdapterStartRejectsACommandThatRequiresTheEnvironmentWhenAnAppHelperFileCannotBeLoaded(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $_SERVER['argv'] = ['qt', $commandName, '--quiet']; + + $this->withBrokenAppHelper(function (): void { + $adapter = $this->createAdapter(); + + $this->expectException(ParseError::class); + + $adapter->start(); + }); + }); + } + + /** + * Puts an app helper file with a syntax error into the test app for the duration of the callback + */ + private function withBrokenAppHelper(callable $test): void + { $directory = PROJECT_ROOT . DS . 'helpers'; $createdDirectory = !is_dir($directory); $file = $directory . DS . 'tmp_broken_helper_' . substr(md5(uniqid('', true)), 0, 8) . '.php'; @@ -505,11 +549,7 @@ public function testConsoleAppAdapterStartReportsTheBootFailureWhenAHelperFileCa $this->assertNotFalse(file_put_contents($file, "createAdapter(); - - $this->expectException(ParseError::class); - - $adapter->start(); + $test(); } finally { if (is_file($file)) { unlink($file); diff --git a/tests/Unit/App/BootPipelineTest.php b/tests/Unit/App/BootPipelineTest.php index 539c77704..c10b549aa 100644 --- a/tests/Unit/App/BootPipelineTest.php +++ b/tests/Unit/App/BootPipelineTest.php @@ -221,16 +221,13 @@ public function testPipelineStagesDeclareBootEventNames(): void $this->assertSame('boot.http.before', InitHttpStage::BEFORE); $this->assertSame('boot.http.after', InitHttpStage::AFTER); - $this->assertSame('boot.console.before', InitConsoleStage::BEFORE); - $this->assertSame('boot.console.after', InitConsoleStage::AFTER); - $this->assertSame('boot.modules.before', LoadModulesStage::BEFORE); $this->assertSame('boot.modules.after', LoadModulesStage::AFTER); } - public function testLoadHelpersInitDebuggerAndSetupConsoleApplicationStagesDeclareNoEvents(): void + public function testLoadHelpersInitConsoleInitDebuggerAndSetupConsoleApplicationStagesDeclareNoEvents(): void { - foreach ([LoadHelpersStage::class, InitDebuggerStage::class, SetupConsoleApplicationStage::class] as $stage) { + foreach ([LoadHelpersStage::class, InitConsoleStage::class, InitDebuggerStage::class, SetupConsoleApplicationStage::class] as $stage) { $this->assertFalse(defined($stage . '::BEFORE'), $stage . ' must not declare BEFORE'); $this->assertFalse(defined($stage . '::AFTER'), $stage . ' must not declare AFTER'); }