diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a41c472a..25956c6b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,13 +11,16 @@ 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 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. - 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()`. - **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`. diff --git a/src/App/Adapters/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index ed9153ae3..1f68f6463 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -10,16 +10,15 @@ 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\ConsoleOutput; use Quantum\App\Stages\Boot\SetupErrorHandlerStage; 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; @@ -37,64 +36,43 @@ */ class ConsoleAppAdapter implements AppInterface { - private const UNKNOWN = 'UNKNOWN'; - 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 InitConsoleStage(), new LoadHelpersStage(), new LoadEnvironmentStage(), new LoadAppConfigStage(), new SetupErrorHandlerStage(), + new SetupConsoleApplicationStage(), ]); $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) { $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(), new RunCommandStage(), ]); + $context = new ConsoleContext($this->context, $this->bootFailure); + try { $pipeline->run($context); } catch (Throwable $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/InitConsoleStage.php b/src/App/Stages/Boot/InitConsoleStage.php new file mode 100644 index 000000000..ffc508544 --- /dev/null +++ b/src/App/Stages/Boot/InitConsoleStage.php @@ -0,0 +1,47 @@ +setName(config()->get('app.name') ?? 'UNKNOWN'); + $application->setVersion(config()->get('app.version') ?? 'UNKNOWN'); + } +} 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 8c568de5a..87f2f92f2 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(); - - $consoleAppAdapter - ->shouldReceive('createApplication') - ->andReturn($applicationMock); + $adapter = $this->createAdapter($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,56 +281,72 @@ 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)); $this->assertTrue(config()->has('app')); } - public function testConsoleAppAdapterMakesEnvironmentMutableForRegularCommands(): void + public function testConsoleAppAdapterDoesNotMakeTheEnvironmentMutableAfterTheFullBoot(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; - $this->consoleAppAdapter->__construct($this->createContext()); + $this->createAdapter(); $property = new ReflectionProperty(Environment::class, 'isMutable'); $property->setAccessible(true); - $this->assertTrue($property->getValue(environment())); + $this->assertFalse($property->getValue(environment())); } public function testConsoleAppAdapterNamesTheApplicationFromTheAppConfigAfterTheFullBoot(): void { $_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()); + }); + } + + 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()); }); } @@ -338,11 +358,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,12 +490,90 @@ public function testConsoleAppAdapterStartReportsTheCommandFailureWhenACommandCa }); } + 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'; + + try { + if ($createdDirectory) { + $this->assertTrue(mkdir($directory)); + } + + $this->assertNotFalse(file_put_contents($file, "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)); @@ -494,20 +592,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); + } + + /** + * Boots the adapter for the command, so start() can be asserted on + */ + private function bootRealAdapter(string $commandName): ConsoleAppAdapter + { + $_SERVER['argv'] = ['qt', $commandName, '--quiet']; - return $adapter; + return $this->createAdapter(); } /** @@ -571,38 +679,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 +698,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()); } 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 eb02b7f82..c10b549aa 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; @@ -9,6 +10,7 @@ use Quantum\App\Stages\Boot\InitDebuggerStage; use Quantum\App\Stages\Boot\LoadModulesStage; use Quantum\App\Stages\Boot\LoadHelpersStage; +use Quantum\App\Stages\Boot\InitConsoleStage; use Quantum\App\Stages\Boot\InitHttpStage; use Quantum\Tests\Unit\AppTestCase; use InvalidArgumentException; @@ -223,9 +225,9 @@ public function testPipelineStagesDeclareBootEventNames(): void $this->assertSame('boot.modules.after', LoadModulesStage::AFTER); } - public function testLoadHelpersAndInitDebuggerStagesDeclareNoEvents(): void + public function testLoadHelpersInitConsoleInitDebuggerAndSetupConsoleApplicationStagesDeclareNoEvents(): void { - foreach ([LoadHelpersStage::class, InitDebuggerStage::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'); } 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)); + } +} 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); } /** 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');