diff --git a/CHANGELOG.md b/CHANGELOG.md index 70e2b781..1a41c472 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 +- 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. @@ -34,6 +37,9 @@ The format is based on Keep a Changelog. - **BREAKING:** Reshaped Lang configuration so `lang.default` now selects the adapter, locale fallback moved to `lang.default_locale`, and the unused `lang.enabled` toggle was removed (#533) - **BREAKING:** Removed `Lang::isEnabled()` from the public Lang API because it no longer affected runtime behavior (#533) +### Fixed +- `core:env` now looks for the existing `.env` in the application base directory, so running it from another working directory asks for the confirmation instead of silently overwriting the file (#575) + ## [3.0.3] - 2026-07-10 ### Changed diff --git a/src/App/Adapters/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index 7d511613..ed9153ae 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -13,17 +13,18 @@ use Quantum\App\Stages\Console\RegisterCoreCommandsStage; use Quantum\App\Stages\Console\RegisterAppCommandsStage; use Symfony\Component\Console\Output\ConsoleOutput; -use Quantum\App\Stages\Console\RunCommandStage; -use Symfony\Component\Console\Input\ArgvInput; use Quantum\App\Stages\Boot\SetupErrorHandlerStage; use Quantum\App\Stages\Boot\LoadEnvironmentStage; -use Symfony\Component\Console\Application; +use Quantum\App\Stages\Console\RunCommandStage; +use Symfony\Component\Console\Input\ArgvInput; use Quantum\App\Stages\Boot\LoadAppConfigStage; 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 Throwable; use Exception; if (!defined('DS')) { @@ -36,6 +37,8 @@ */ class ConsoleAppAdapter implements AppInterface { + private const UNKNOWN = 'UNKNOWN'; + protected AppContext $context; protected ArgvInput $input; @@ -44,6 +47,8 @@ class ConsoleAppAdapter implements AppInterface protected Application $application; + protected ?Throwable $bootFailure = null; + public function __construct(AppContext $context) { $this->context = $context; @@ -51,29 +56,25 @@ public function __construct(AppContext $context) $this->input = new ArgvInput(); $this->output = new ConsoleOutput(); - $commandName = $this->input->getFirstArgument(); - - $stages = [ - new LoadHelpersStage(), - ]; + $this->application = $this->createApplication(self::UNKNOWN, self::UNKNOWN); - if ($commandName !== 'core:env') { - $stages[] = new LoadEnvironmentStage(); - $stages[] = new LoadAppConfigStage(); - $stages[] = new SetupErrorHandlerStage(); - } + try { + $pipeline = new BootPipeline([ + new LoadHelpersStage(), + new LoadEnvironmentStage(), + new LoadAppConfigStage(), + new SetupErrorHandlerStage(), + ]); - $pipeline = new BootPipeline($stages); - $pipeline->run($this->context); + $pipeline->run($this->context); - if ($commandName !== 'core:env') { environment()->setMutable(true); - } - $this->application = $this->createApplication( - config()->get('app.name', 'UNKNOWN'), - config()->get('app.version', 'UNKNOWN') - ); + $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 @@ -82,11 +83,11 @@ public function createApplication(string $name, string $version): Application } /** - * @throws Exception + * @throws Exception|Throwable */ public function start(): ?int { - $context = new ConsoleContext($this->context, $this->application, $this->input, $this->output); + $context = new ConsoleContext($this->context, $this->application, $this->input, $this->output, $this->bootFailure); $pipeline = new ConsolePipeline([ new RegisterCoreCommandsStage(), @@ -94,7 +95,11 @@ public function start(): ?int new RunCommandStage(), ]); - $pipeline->run($context); + try { + $pipeline->run($context); + } catch (Throwable $exception) { + throw $this->bootFailure ?? $exception; + } return $context->getExitCode() ?: ExitCode::SUCCESS; } diff --git a/src/App/Adapters/Console/ConsoleContext.php b/src/App/Adapters/Console/ConsoleContext.php index c97cbe68..469624bb 100644 --- a/src/App/Adapters/Console/ConsoleContext.php +++ b/src/App/Adapters/Console/ConsoleContext.php @@ -15,6 +15,7 @@ use Symfony\Component\Console\Application; use Quantum\App\Enums\ExitCode; use Quantum\App\AppContext; +use Throwable; /** * Class ConsoleContext @@ -34,18 +35,22 @@ class ConsoleContext private OutputInterface $output; + private ?Throwable $bootFailure; + private int $exitCode = ExitCode::SUCCESS; public function __construct( AppContext $appContext, Application $application, InputInterface $input, - OutputInterface $output + OutputInterface $output, + ?Throwable $bootFailure = null ) { $this->appContext = $appContext; $this->application = $application; $this->input = $input; $this->output = $output; + $this->bootFailure = $bootFailure; } public function getAppContext(): AppContext @@ -68,6 +73,14 @@ public function getOutput(): OutputInterface return $this->output; } + /** + * The failure of the boot that the console run continued after, if there was one + */ + public function getBootFailure(): ?Throwable + { + return $this->bootFailure; + } + public function getExitCode(): int { return $this->exitCode; diff --git a/src/App/Stages/Console/RunCommandStage.php b/src/App/Stages/Console/RunCommandStage.php index fb8cc28c..facb4d52 100644 --- a/src/App/Stages/Console/RunCommandStage.php +++ b/src/App/Stages/Console/RunCommandStage.php @@ -12,19 +12,22 @@ use Quantum\App\Contracts\ConsoleStageInterface; use Quantum\App\Adapters\Console\ConsoleContext; +use Quantum\Console\CliCommand; use Exception; +use Throwable; /** * Class RunCommandStage * - * Runs the requested command; a missing or unknown command is rejected before anything runs. + * Runs the requested command; a missing or unknown command is rejected before anything runs, + * and so is a command that needs the environment after the boot failed. * * @package Quantum\App */ class RunCommandStage implements ConsoleStageInterface { /** - * @throws Exception + * @throws Exception|Throwable */ public function process(ConsoleContext $context): void { @@ -34,6 +37,12 @@ public function process(ConsoleContext $context): void throw new Exception("Command `$commandName` is not defined"); } + $command = $context->getApplication()->get($commandName); + + if ($context->getBootFailure() !== null && $command instanceof CliCommand && $command->requiresEnvironment()) { + throw $context->getBootFailure(); + } + $context->setExitCode( $context->getApplication()->run($context->getInput(), $context->getOutput()) ); diff --git a/src/Console/CliCommand.php b/src/Console/CliCommand.php index 6a26cb5a..56af49f2 100644 --- a/src/Console/CliCommand.php +++ b/src/Console/CliCommand.php @@ -54,6 +54,11 @@ abstract class CliCommand extends Command implements CommandInterface */ protected array $options = []; + /** + * Whether the command needs the environment file to run + */ + protected bool $requiresEnvironment = true; + protected ?InputInterface $input = null; protected ?OutputInterface $output = null; @@ -74,6 +79,14 @@ public function __construct() } } + /** + * Tells whether the command needs the environment file to run + */ + public function requiresEnvironment(): bool + { + return $this->requiresEnvironment; + } + /** * Returns the argument value for a given argument name. * @return mixed|string diff --git a/src/Console/Commands/EnvCommand.php b/src/Console/Commands/EnvCommand.php index 4dfadf17..171bf7ea 100644 --- a/src/Console/Commands/EnvCommand.php +++ b/src/Console/Commands/EnvCommand.php @@ -46,6 +46,11 @@ class EnvCommand extends CliCommand ['yes', 'y', 'none', 'Acceptance of the confirmation'], ]; + /** + * The command creates the environment file, so it must run when there is none + */ + protected bool $requiresEnvironment = false; + /** * Executes the command and creates new .env file * @throws BaseException @@ -61,7 +66,7 @@ public function exec(): void $this->error('.env.example file not found'); } - if ($fs->exists('.env') && !$this->getOption('yes') && !$this->confirm('The operation will overwrite values of the existing .env and will create new one from .env.example. Continue?')) { + if ($fs->exists(App::getBaseDir() . DS . '.env') && !$this->getOption('yes') && !$this->confirm('The operation will overwrite values of the existing .env and will create new one from .env.example. Continue?')) { $this->info('Operation was canceled!'); return; } diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index 0be1b04a..8c568de5 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -3,11 +3,18 @@ namespace Quantum\Tests\Unit\App\Adapters\Console; use Quantum\App\Adapters\Console\ConsoleAppAdapter; +use Quantum\Environment\Exceptions\EnvException; +use Quantum\App\Stages\Boot\LoadHelpersStage; use Symfony\Component\Console\Application; +use Dotenv\Exception\InvalidFileException; use Quantum\Environment\Environment; use Quantum\Tests\Unit\AppTestCase; use Quantum\Tracer\ErrorHandler; +use Quantum\App\BootPipeline; use Quantum\Di\Di; +use ReflectionProperty; +use RuntimeException; +use Throwable; use Exception; use Mockery; @@ -201,15 +208,378 @@ public function testConsoleAppAdapterBootsAllStagesForRegularCommands(): void $this->assertTrue(config()->has('app')); } - public function testConsoleAppAdapterBootsReducedStagesForCoreEnvCommand(): void + public function testConsoleAppAdapterBootsAllStagesForCoreEnvCommandWhenTheEnvFileExists(): void { $_SERVER['argv'] = ['qt', 'core:env']; $this->consoleAppAdapter->__construct($this->createContext()); - $this->assertFalse(Di::has(Environment::class)); - $this->assertFalse(Di::isRegistered(ErrorHandler::class)); - $this->assertFalse(config()->has('app')); + $this->assertTrue(Di::has(Environment::class)); + $this->assertTrue(Di::isRegistered(ErrorHandler::class)); + $this->assertTrue(config()->has('app')); + } + + public function testConsoleAppAdapterRecordsNoBootFailureAfterASuccessfulBoot(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->assertNull($this->bootFailureOf($this->consoleAppAdapter)); + } + + public function testConsoleAppAdapterRecordsTheBootFailureInsteadOfThrowingWhenTheEnvFileIsMissing(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $this->withoutEnvironmentFile(function (): void { + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->assertInstanceOf(EnvException::class, $this->bootFailureOf($this->consoleAppAdapter)); + }); + } + + public function testConsoleAppAdapterDoesNotMakeTheEnvironmentMutableAfterAFailedBoot(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $this->withoutEnvironmentFile(function (): void { + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->assertFalse(Di::has(Environment::class)); + }); + } + + public function testConsoleAppAdapterNamesTheApplicationUnknownWhenTheAppNameIsEmpty(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $context = $this->createContext(); + + (new BootPipeline([new LoadHelpersStage()]))->run($context); + + config()->set('app', ['name' => null, 'version' => null]); + + $adapter = new ConsoleAppAdapter($context); + + $this->assertNull($this->bootFailureOf($adapter)); + $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getName()); + $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getVersion()); + } + + public function testConsoleAppAdapterKeepsAnEmptyAppNameAndVersionFromTheConfig(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $context = $this->createContext(); + + (new BootPipeline([new LoadHelpersStage()]))->run($context); + + config()->set('app', ['name' => '', 'version' => '0']); + + $adapter = new ConsoleAppAdapter($context); + + $this->assertNull($this->bootFailureOf($adapter)); + $this->assertSame('', $this->applicationOf($adapter)->getName()); + $this->assertSame('0', $this->applicationOf($adapter)->getVersion()); + } + + public function testConsoleAppAdapterBootsAllStagesForUnknownCommand(): void + { + $_SERVER['argv'] = ['qt', 'unknown:command', '--quiet']; + + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->assertTrue(Di::has(Environment::class)); + $this->assertTrue(Di::isRegistered(ErrorHandler::class)); + $this->assertTrue(config()->has('app')); + } + + public function testConsoleAppAdapterMakesEnvironmentMutableForRegularCommands(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $this->consoleAppAdapter->__construct($this->createContext()); + + $property = new ReflectionProperty(Environment::class, 'isMutable'); + $property->setAccessible(true); + + $this->assertTrue($property->getValue(environment())); + } + + public function testConsoleAppAdapterNamesTheApplicationFromTheAppConfigAfterTheFullBoot(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $adapter = new ConsoleAppAdapter($this->createContext()); + + $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()); + } + + public function testConsoleAppAdapterNamesTheApplicationUnknownAfterAFailedBoot(): void + { + $_SERVER['argv'] = ['qt', 'core:env']; + + $this->withoutEnvironmentFile(function (): void { + $adapter = new ConsoleAppAdapter($this->createContext()); + + $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getName()); + $this->assertSame('UNKNOWN', $this->applicationOf($adapter)->getVersion()); + }); + } + + public function testConsoleAppAdapterStartRejectsACommandThatRequiresTheEnvironmentWhenTheEnvFileIsMissing(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $_SERVER['argv'] = ['qt', $commandName, '--quiet']; + + $this->withoutEnvironmentFile(function (): void { + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->expectException(EnvException::class); + + $this->consoleAppAdapter->start(); + }); + }); + } + + public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMissing(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withoutEnvironmentFile(function () use ($commandName): void { + $this->assertSame(0, $this->startWithRealApplication($commandName)); + $this->assertSame($commandName, $GLOBALS['tmpCommandRan'] ?? null); + }); + }, null, false); + } + + public function testConsoleAppAdapterStartRunsListWhenTheEnvFileIsMissing(): void + { + $this->withoutEnvironmentFile(function (): void { + $this->assertSame(0, $this->startWithRealApplication('list')); + }); + } + + public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMalformed(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withEnvironmentFileContent("APP_NAME=two words here\n", function () use ($commandName): void { + $adapter = $this->bootRealAdapter($commandName); + + $this->assertSame(0, $adapter->start()); + $this->assertSame($commandName, $GLOBALS['tmpCommandRan'] ?? null); + }); + }, null, false); + } + + public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsEmpty(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withEnvironmentFileContent('', function () use ($commandName): void { + $adapter = $this->bootRealAdapter($commandName); + + $this->assertSame(0, $adapter->start()); + $this->assertSame($commandName, $GLOBALS['tmpCommandRan'] ?? null); + }); + }, null, false); + } + + public function testConsoleAppAdapterStartRejectsACommandThatRequiresTheEnvironmentWhenTheEnvFileIsMalformed(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withEnvironmentFileContent("APP_NAME=two words here\n", function () use ($commandName): void { + $adapter = $this->bootRealAdapter($commandName); + + try { + $adapter->start(); + $this->fail('The command must not run when the environment file is malformed.'); + } catch (InvalidFileException $exception) { + $this->assertArrayNotHasKey('tmpCommandRan', $GLOBALS); + } + }); + }); + } + + public function testConsoleAppAdapterStartRunsListWhenTheEnvFileIsMalformed(): void + { + $this->withEnvironmentFileContent("APP_NAME=two words here\n", function (): void { + $this->assertSame(0, $this->bootRealAdapter('list')->start()); + }); + } + + public function testConsoleAppAdapterStartReportsTheBootFailureForAnUnknownCommandWhenTheEnvFileIsMalformed(): void + { + $this->withEnvironmentFileContent("APP_NAME=two words here\n", function (): void { + $adapter = $this->bootRealAdapter('unknown:command'); + + $this->expectException(InvalidFileException::class); + + $adapter->start(); + }); + } + + public function testConsoleAppAdapterStartReportsTheBootFailureForAnUnknownCommandWhenTheEnvFileIsMissing(): void + { + $this->withoutEnvironmentFile(function (): void { + $adapter = $this->bootRealAdapter('unknown:command'); + + $this->expectException(EnvException::class); + + $adapter->start(); + }); + } + + public function testConsoleAppAdapterStartReportsTheBootFailureWhenACommandCannotBeCreatedAfterAFailedBoot(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withBrokenCommand($directory, function (): void { + $this->withoutEnvironmentFile(function (): void { + $adapter = $this->bootRealAdapter('list'); + + $this->expectException(EnvException::class); + + $adapter->start(); + }); + }); + } + + public function testConsoleAppAdapterStartReportsTheCommandFailureWhenACommandCannotBeCreatedAfterASuccessfulBoot(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withBrokenCommand($directory, function (): void { + $adapter = $this->bootRealAdapter('list'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The command cannot be created'); + + $adapter->start(); + }); + } + + /** + * Replaces the content of the environment file of the test app for the duration of the callback + */ + private function withEnvironmentFileContent(string $content, callable $test): void + { + $file = PROJECT_ROOT . DS . '.env.testing'; + $backup = $file . '.backup'; + + $this->assertTrue(rename($file, $backup)); + + try { + $this->assertNotFalse(file_put_contents($file, $content)); + + $test(); + } finally { + if (is_file($file)) { + unlink($file); + } + + rename($backup, $file); + } + } + + /** + * 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 + */ + private function bootRealAdapter(string $commandName): ConsoleAppAdapter + { + $_SERVER['argv'] = ['qt', $commandName, '--quiet']; + + $adapter = new ConsoleAppAdapter($this->createContext()); + + $application = $this->applicationOf($adapter); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + + return $adapter; + } + + /** + * Puts an app command whose constructor throws into the directory for the duration of the test + */ + private function withBrokenCommand(string $directory, callable $test): void + { + $className = 'TmpBrokenCommand' . substr(md5(uniqid('', true)), 0, 8); + + $command = <<withTemporaryFile($directory, $className, $command, $test); + } + + /** + * Hides the environment file of the test app for the duration of the callback + */ + private function withoutEnvironmentFile(callable $test): void + { + $file = PROJECT_ROOT . DS . '.env.testing'; + $hidden = $file . '.hidden'; + + $this->assertTrue(rename($file, $hidden)); + + try { + $test(); + } finally { + rename($hidden, $file); + } + } + + /** + * The failure the adapter recorded while booting, if there was one + */ + private function bootFailureOf(ConsoleAppAdapter $adapter): ?Throwable + { + $property = new ReflectionProperty(ConsoleAppAdapter::class, 'bootFailure'); + $property->setAccessible(true); + + 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); } /** @@ -263,13 +633,15 @@ private function registeredCommandNames(): array * Creates a temporary command class in the given directory, loads it, runs the test * with the command name, and removes the file and any directories it had to create. * The command records that it ran, and from which namespace, in $GLOBALS['tmpCommandRan'] and - * $GLOBALS['tmpCommandSource']. A fixed command name creates two commands with the same name. + * $GLOBALS['tmpCommandSource']. A fixed command name creates two commands with the same name, + * and the command can declare that it does not require the environment. */ - private function withTemporaryCommand(string $directory, string $namespace, callable $test, ?string $name = null): void + private function withTemporaryCommand(string $directory, string $namespace, callable $test, ?string $name = null, bool $requiresEnvironment = true): void { $suffix = substr(md5(uniqid('', true)), 0, 8); $className = 'TmpCommand' . $suffix; $commandName = $name ?? 'tmp:command' . $suffix; + $requires = $requiresEnvironment ? 'true' : 'false'; $command = <<assertSame($this->output, $this->consoleContext->getOutput()); } + public function testConsoleContextHasNoBootFailureByDefault(): void + { + $this->assertNull($this->consoleContext->getBootFailure()); + } + + public function testConsoleContextExposesTheBootFailure(): void + { + $failure = new RuntimeException('The boot failed'); + + $consoleContext = new ConsoleContext($this->appContext, $this->application, $this->input, $this->output, $failure); + + $this->assertSame($failure, $consoleContext->getBootFailure()); + } + public function testConsoleContextDefaultsExitCodeToSuccess(): void { $this->assertSame(ExitCode::SUCCESS, $this->consoleContext->getExitCode()); diff --git a/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php b/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php index 37ac4232..410c6563 100644 --- a/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php +++ b/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php @@ -8,6 +8,7 @@ use Quantum\Tests\Unit\AppTestCase; use Quantum\App\Adapters\Console\ConsoleContext; use Quantum\App\App; +use Throwable; abstract class ConsoleStageTestCase extends AppTestCase { @@ -15,13 +16,13 @@ abstract class ConsoleStageTestCase extends AppTestCase * Creates a console context around a real Symfony application that neither exits nor catches exceptions. * @param array $input */ - protected function createConsoleContext(array $input = [], ?Application $application = null): ConsoleContext + protected function createConsoleContext(array $input = [], ?Application $application = null, ?Throwable $bootFailure = null): ConsoleContext { $application ??= new Application(); $application->setAutoExit(false); $application->setCatchExceptions(false); - return new ConsoleContext(App::getContext(), $application, new ArrayInput($input), new BufferedOutput()); + return new ConsoleContext(App::getContext(), $application, new ArrayInput($input), new BufferedOutput(), $bootFailure); } /** diff --git a/tests/Unit/App/Stages/Console/RunCommandStageTest.php b/tests/Unit/App/Stages/Console/RunCommandStageTest.php index c823b510..9daf8a8b 100644 --- a/tests/Unit/App/Stages/Console/RunCommandStageTest.php +++ b/tests/Unit/App/Stages/Console/RunCommandStageTest.php @@ -8,6 +8,8 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Application; use Quantum\App\Enums\ExitCode; +use Quantum\Console\CliCommand; +use RuntimeException; use Exception; class RunCommandStageTest extends ConsoleStageTestCase @@ -83,6 +85,88 @@ public function testRunCommandStageRunsNothingWhenTheCommandNameIsMissing(): voi $this->assertSame('', $context->getOutput()->fetch()); } + public function testRunCommandStageRejectsUnknownCommandWhenTheBootFailed(): void + { + $context = $this->createConsoleContext(['command' => 'unknown:command'], $this->createApplication('run:ok', 0), new RuntimeException('The boot failed')); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Command `unknown:command` is not defined'); + + (new RunCommandStage())->process($context); + } + + public function testRunCommandStageRejectsACommandThatRequiresTheEnvironmentWhenTheBootFailed(): void + { + $failure = new RuntimeException('The boot failed'); + $context = $this->createConsoleContext(['command' => 'needs:env'], $this->createCliApplication('needs:env', true), $failure); + + try { + (new RunCommandStage())->process($context); + + $this->fail('The command must not run after a failed boot.'); + } catch (RuntimeException $e) { + $this->assertSame($failure, $e); + } + + $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); + $this->assertSame('', $context->getOutput()->fetch()); + } + + public function testRunCommandStageRunsACommandThatDoesNotRequireTheEnvironmentWhenTheBootFailed(): void + { + $context = $this->createConsoleContext(['command' => 'opt:out'], $this->createCliApplication('opt:out', false), new RuntimeException('The boot failed')); + + (new RunCommandStage())->process($context); + + $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); + $this->assertStringContainsString('ran opt:out', $context->getOutput()->fetch()); + } + + public function testRunCommandStageRunsAPlainSymfonyCommandWhenTheBootFailed(): void + { + $context = $this->createConsoleContext(['command' => 'run:ok'], $this->createApplication('run:ok', 0), new RuntimeException('The boot failed')); + + (new RunCommandStage())->process($context); + + $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); + $this->assertStringContainsString('hello from run:ok', $context->getOutput()->fetch()); + } + + public function testRunCommandStageRunsACommandThatRequiresTheEnvironmentWhenTheBootSucceeded(): void + { + $context = $this->createConsoleContext(['command' => 'needs:env'], $this->createCliApplication('needs:env', true)); + + (new RunCommandStage())->process($context); + + $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); + $this->assertStringContainsString('ran needs:env', $context->getOutput()->fetch()); + } + + /** + * An application with one CliCommand that does or does not require the environment + */ + private function createCliApplication(string $commandName, bool $requiresEnvironment): Application + { + $application = new Application(); + + $application->add(new class ($commandName, $requiresEnvironment) extends CliCommand { + public function __construct(string $commandName, bool $requiresEnvironment) + { + $this->name = $commandName; + $this->requiresEnvironment = $requiresEnvironment; + + parent::__construct(); + } + + public function exec(): void + { + $this->output('ran ' . $this->name); + } + }); + + return $application; + } + private function createApplication(string $commandName, int $exitCode): Application { $application = new Application(); diff --git a/tests/Unit/Console/CliCommandTest.php b/tests/Unit/Console/CliCommandTest.php index ddfa9222..c280091e 100644 --- a/tests/Unit/Console/CliCommandTest.php +++ b/tests/Unit/Console/CliCommandTest.php @@ -36,6 +36,11 @@ public function testCliCommandMetadataIsConfigured(): void $this->assertSame('Used only for core command discovery tests', $this->command->getHelp()); } + public function testCliCommandRequiresTheEnvironmentByDefault(): void + { + $this->assertTrue($this->command->requiresEnvironment()); + } + public function testCliCommandArgumentsAndOptionsAreRegistered(): void { $definition = $this->command->getDefinition(); diff --git a/tests/Unit/Console/Commands/EnvCommandTest.php b/tests/Unit/Console/Commands/EnvCommandTest.php index 6fc9979b..717b7b00 100644 --- a/tests/Unit/Console/Commands/EnvCommandTest.php +++ b/tests/Unit/Console/Commands/EnvCommandTest.php @@ -2,7 +2,9 @@ namespace Quantum\Tests\Unit\Console\Commands; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Console\Helper\HelperSet; use Quantum\Console\Commands\EnvCommand; use Quantum\Tests\Unit\AppTestCase; @@ -27,6 +29,71 @@ public function testCommandMetadata(): void $this->assertSame('The command will generate new .env file from .env.example', $this->command->getHelp()); } + public function testCommandDoesNotRequireTheEnvironment(): void + { + $this->assertFalse($this->command->requiresEnvironment()); + } + + public function testExecAsksForConfirmationWhenTheBaseDirEnvFileExists(): void + { + $envFile = base_dir() . DS . '.env'; + $workingDirectory = getcwd(); + + $this->assertFileDoesNotExist($envFile); + + try { + $this->createFile($envFile, "ORIGINAL=1\n"); + + // A different working directory shows that the command looks at the base dir and not at the current dir + chdir(sys_get_temp_dir()); + + $this->command->setHelperSet(new HelperSet(['question' => new QuestionHelper()])); + $this->tester->setInputs(['no']); + $this->tester->execute([]); + + $this->assertStringContainsString('Operation was canceled!', $this->tester->getDisplay()); + $this->assertSame("ORIGINAL=1\n", file_get_contents($envFile)); + } finally { + chdir($workingDirectory); + $this->removeFile($envFile); + } + } + + public function testExecOverwritesTheBaseDirEnvFileWithoutAskingWhenYesIsGiven(): void + { + $envFile = base_dir() . DS . '.env'; + + $this->assertFileDoesNotExist($envFile); + + try { + $this->createFile($envFile, "ORIGINAL=1\n"); + + $this->tester->execute(['--yes' => true]); + + $this->assertStringContainsString('.env is successfully generated', $this->tester->getDisplay()); + $this->assertSame(file_get_contents(base_dir() . DS . '.env.example'), file_get_contents($envFile)); + } finally { + $this->removeFile($envFile); + } + } + + public function testExecCreatesTheEnvFileWithoutAskingWhenThereIsNone(): void + { + $envFile = base_dir() . DS . '.env'; + + $this->assertFileDoesNotExist($envFile); + + try { + $this->tester->execute([]); + + $this->assertStringContainsString('.env is successfully generated', $this->tester->getDisplay()); + $this->assertFileExists($envFile); + $this->assertSame(file_get_contents(base_dir() . DS . '.env.example'), file_get_contents($envFile)); + } finally { + $this->removeFile($envFile); + } + } + public function testExecShowsErrorWhenEnvExampleMissing(): void { $envExample = base_dir() . DS . '.env.example';