diff --git a/.cursor/rules/01-architecture.mdc b/.cursor/rules/01-architecture.mdc index 836a6436..7f6632c5 100644 --- a/.cursor/rules/01-architecture.mdc +++ b/.cursor/rules/01-architecture.mdc @@ -130,14 +130,6 @@ $service = $container->build(TestService::class); - Separate section headers, subheaders and paragraphs with a single newline - Avoid commenting the obvious or leaving comments behind when removing code -**ALWAYS use the correct section header comment format and not the simplified one:** - -``` - // - // {Section Header} - // ---- ❌ Too few dashes -``` - ### Quality Gates **ALWAYS run these commands against the files you have touched and fix any issues BEFORE considering a task complete:** diff --git a/.cursor/rules/03-commands.mdc b/.cursor/rules/03-commands.mdc index 91b1c3e7..f7d20c84 100644 --- a/.cursor/rules/03-commands.mdc +++ b/.cursor/rules/03-commands.mdc @@ -21,14 +21,14 @@ alwaysApply: true ```php // ✅ CORRECT - BaseCommand custom methods $this->writeln(['Multiple', 'lines']); // Multi-line output -$this->text('Single line message'); // Simple text output $this->hr(); // Beautiful section separator $this->h1('Section Heading'); // Heading with icon // Status messages $this->success('Server added successfully'); -$this->error('Failed to connect', 'Check your SSH key permissions'); +$this->error('Failed to connect to server'); $this->warning('Skipping connection check'); +$this->info('Configuration loaded'); ``` **❌ FORBIDDEN - Direct Symfony IO Usage:** @@ -61,9 +61,9 @@ protected function success(string $message): void { - Base implementation: [BaseCommand.php](mdc:app/Contracts/BaseCommand.php) - Output trait: [ConsoleOutputTrait.php](mdc:app/Traits/ConsoleOutputTrait.php) - Input trait: [ConsoleInputTrait.php](mdc:app/Traits/ConsoleInputTrait.php) -- Output methods: `writeln()`, `text()`, `info()`, `note()`, `hr()`, `h1()` +- Output methods: `writeln()`, `info()`, `hr()`, `h1()` - Status methods: `success()`, `error()`, `warning()` -- Input methods: `getOptionOrPrompt()` +- Input methods: `getOptionOrPrompt()`, `promptText()`, `promptPassword()`, `promptConfirm()`, `promptSelect()`, `promptMultiselect()`, `promptSuggest()`, `promptSearch()`, `promptPause()`, `promptSpin()` - Helper methods: `showCommandHint()` **When to Add New Methods:** @@ -93,12 +93,13 @@ This separation ensures console I/O methods remain reusable across different com $this->success('Server added successfully'); $this->success('Connection successful'); -// ✅ Error messages (red X) with optional tip +// ✅ Error messages (red X) $this->error('Failed to connect to server'); -$this->error( - 'Failed to add server: ' . $e->getMessage(), - 'Use server:list to view existing servers.' -); +$this->error('Failed to add server: ' . $e->getMessage()); + +// ✅ Info messages (cyan info symbol) +$this->info('Configuration loaded from .env'); +$this->info('Using custom inventory path'); // ✅ Warning messages (yellow warning symbol) $this->warning('Skipping SSH connection check'); @@ -108,9 +109,8 @@ $this->warning('Server add cancelled'); **Benefits:** - Consistent formatting across all commands -- Automatic spacing (blank line after message) -- Clear visual indicators (✓, ✗, ⚠) -- Optional tips for error messages +- Clear visual indicators (✓, ✗, ℹ, ⚠) +- Modern, colorful output styling ### 🎯 User Input with Laravel Prompts @@ -160,7 +160,8 @@ $result = spin(fn() => $this->service->heavyOperation(), 'Processing...'); class MyCommand extends BaseCommand { protected function execute(InputInterface $input, OutputInterface $output): int { // ✅ CORRECT - Custom BaseCommand methods - $this->text('Starting process...'); + $this->h1('Deployment'); + $this->info('Starting deployment process...'); // Prompt user with Laravel Prompts $confirmed = confirm('Continue with deployment?', default: true); @@ -169,7 +170,7 @@ class MyCommand extends BaseCommand { $result = $this->service->performWork(); // Custom output formatting - $this->writeln('✓ Completed: ' . $result); + $this->success('Deployment completed: ' . $result); return Command::SUCCESS; } @@ -198,32 +199,41 @@ protected function configure(): void { } protected function execute(InputInterface $input, OutputInterface $output): int { - // Track which options were provided vs prompted - $provided = []; - - // Use BaseCommand helper method (uses $this->input internally) + // Use getOptionOrPrompt with closures for flexible prompting $name = $this->getOptionOrPrompt( 'name', - 'Server name:', - placeholder: 'production-web-01', - wasProvided: $provided['name'] + fn() => $this->promptText( + label: 'Server name:', + placeholder: 'production-web-01', + required: true + ) ); $host = $this->getOptionOrPrompt( 'host', - 'Host/IP address:', - placeholder: '192.168.1.100', - wasProvided: $provided['host'] + fn() => $this->promptText( + label: 'Host/IP address:', + placeholder: '192.168.1.100', + required: true + ) + ); + + $skipConfirm = $this->getOptionOrPrompt( + 'yes', + fn() => $this->promptConfirm( + label: 'Skip verification?', + default: false + ) ); // ... process command ... - // Show command hint with highlighted missing options + // Show command hint for non-interactive usage $this->showCommandHint('my:command', [ 'name' => $name, 'host' => $host, - 'yes' => true, - ], $provided); + 'yes' => $skipConfirm, + ]); return Command::SUCCESS; } @@ -233,20 +243,47 @@ protected function execute(InputInterface $input, OutputInterface $output): int The `getOptionOrPrompt()` method is available in BaseCommand for all commands: +- **Signature:** `getOptionOrPrompt(string $optionName, Closure $promptCallback): mixed` - Checks if option was provided via CLI -- If yes, returns the value and sets `wasProvided` to `true` -- If not, prompts user interactively and sets `wasProvided` to `false` -- Supports all Laravel Prompts parameters (label, default, required, placeholder) +- If yes, returns the option value directly +- If not, executes the closure to prompt user interactively +- Supports any return type (string, bool, int, array) via the closure +- Works with all prompt methods: `promptText()`, `promptSelect()`, `promptConfirm()`, etc. + +**Prompt Wrapper Methods:** + +ConsoleInputTrait provides wrappers for all Laravel Prompts functions: + +- `promptText()` - Text input with validation +- `promptPassword()` - Password input (masked) +- `promptConfirm()` - Yes/No confirmation +- `promptSelect()` - Single selection from list +- `promptMultiselect()` - Multiple selections +- `promptSuggest()` - Autocomplete suggestions +- `promptSearch()` - Searchable options +- `promptPause()` - Wait for Enter key +- `promptSpin()` - Loading spinner for operations + +All wrappers automatically suppress extra spacing for cleaner output. **Command Hint Output:** -- Gray = option was provided -- Bright yellow = option was prompted (user should include next time) -- Shows complete non-interactive command for copy/paste +Shows the complete non-interactive command with all options for easy copy/paste: + +``` +◆ Run non-interactively: + + vendor/bin/deployer my:command \ + --name='production-web-01' \ + --host='192.168.1.100' \ + --yes +``` **Benefits:** - Script-friendly with full option support - User-friendly with interactive fallbacks - Educational - teaches users the non-interactive syntax +- Flexible - supports any prompt type via closures +- Type-safe - preserves return types from prompts - DRY - single command serves both use cases diff --git a/app/Traits/ConsoleInputTrait.php b/app/Traits/ConsoleInputTrait.php index 7d66dc08..c8374c8c 100644 --- a/app/Traits/ConsoleInputTrait.php +++ b/app/Traits/ConsoleInputTrait.php @@ -4,6 +4,16 @@ namespace Bigpixelrocket\DeployerPHP\Traits; +use Closure; + +use function Laravel\Prompts\confirm; +use function Laravel\Prompts\multiselect; +use function Laravel\Prompts\password; +use function Laravel\Prompts\pause; +use function Laravel\Prompts\search; +use function Laravel\Prompts\select; +use function Laravel\Prompts\spin; +use function Laravel\Prompts\suggest; use function Laravel\Prompts\text; /** @@ -17,40 +27,328 @@ trait ConsoleInputTrait * Get option value or prompt user interactively. * * Checks if an option was provided via CLI. If yes, returns it. - * If not, prompts the user interactively using Laravel Prompts. + * If not, prompts the user interactively using a custom closure. + * + * @template T * * @param string $optionName The option name to check - * @param string $label The prompt label for interactive input - * @param string $default Default value for the prompt - * @param bool $required Whether the input is required - * @param string $placeholder Placeholder text for the prompt - * @param-out bool $wasProvided Set to true if option was provided, false if prompted + * @param Closure(): T $promptCallback Closure that performs the actual prompting (e.g., text(), select(), confirm()) + * + * @return string|bool|T The option value or prompted input + * + * @example + * // Text input + * $name = $this->getOptionOrPrompt( + * 'name', + * fn() => text('Server name:', placeholder: 'web1') + * ); * - * @return string The option value or prompted input + * // Boolean flag (VALUE_NONE option) + * $skip = $this->getOptionOrPrompt( + * 'skip', + * fn() => confirm('Skip verification?', default: false) + * ); + * + * // Select input + * $env = $this->getOptionOrPrompt( + * 'environment', + * fn() => select('Environment:', ['dev', 'staging', 'prod']) + * ); */ protected function getOptionOrPrompt( string $optionName, + Closure $promptCallback + ): mixed { + $value = $this->input->getOption($optionName); + + // Handle boolean flags (for VALUE_NONE options) + if (is_bool($value) && $value === true) { + return true; + } + + // Handle string options with non-empty values + if (is_string($value) && $value !== '') { + return $value; + } + + // Prompt user interactively + return $promptCallback(); + } + + // + // Laravel Prompts Wrappers + // ------------------------------------------------------------------------------- + + /** + * Remove the annoying newline that Laravel Prompts adds before each prompt. + * + * Uses ANSI escape sequence to move cursor up one line and clear it. + */ + private function suppressPromptSpacing(): void + { + // Move cursor up one line and clear it + // This compensates for the newline Laravel Prompts adds + echo "\033[1A\033[2K"; + } + + /** + * Prompt for text input. + * + * @param string $label The question to display + * @param string $placeholder Optional placeholder text + * @param string $default Optional default value + * @param bool $required Whether input is required + * @param mixed $validate Optional validation callback + * @param string $hint Optional hint text + * + * @return string The user's input + */ + protected function promptText( string $label, + string $placeholder = '', string $default = '', bool $required = true, + mixed $validate = null, + string $hint = '' + ): string { + $this->suppressPromptSpacing(); + + return text( + label: $label, + placeholder: $placeholder, + default: $default, + required: $required, + validate: $validate, + hint: $hint + ); + } + + /** + * Prompt for password input. + * + * @param string $label The question to display + * @param string $placeholder Optional placeholder text + * @param bool $required Whether input is required + * @param mixed $validate Optional validation callback + * @param string $hint Optional hint text + * + * @return string The user's password input + */ + protected function promptPassword( + string $label, string $placeholder = '', - ?bool &$wasProvided = null + bool $required = true, + mixed $validate = null, + string $hint = '' ): string { - $value = $this->input->getOption($optionName); + $this->suppressPromptSpacing(); - if (is_string($value) && $value !== '') { - $wasProvided = true; + return password( + label: $label, + placeholder: $placeholder, + required: $required, + validate: $validate, + hint: $hint + ); + } - return $value; - } + /** + * Prompt for yes/no confirmation. + * + * @param string $label The question to display + * @param bool $default Default value (true = yes, false = no) + * @param string $yes Text for "yes" option + * @param string $no Text for "no" option + * @param string $hint Optional hint text + * + * @return bool True if confirmed, false otherwise + */ + protected function promptConfirm( + string $label, + bool $default = true, + string $yes = 'Yes', + string $no = 'No', + string $hint = '' + ): bool { + $this->suppressPromptSpacing(); - $wasProvided = false; + return confirm( + label: $label, + default: $default, + yes: $yes, + no: $no, + hint: $hint + ); + } - return text( + /** + * Display a message and wait for user to press Enter. + * + * @param string $message The message to display + * + * @return bool Always returns a boolean + */ + protected function promptPause(string $message = 'Press enter to continue...'): bool + { + $this->suppressPromptSpacing(); + + return pause($message); + } + + /** + * Prompt for single selection from options. + * + * @param string $label The question to display + * @param array $options Available options + * @param int|string|null $default Default option key + * @param int $scroll Number of visible options + * @param mixed $validate Optional validation callback + * @param string $hint Optional hint text + * + * @return int|string The selected option key + */ + protected function promptSelect( + string $label, + array $options, + int|string|null $default = null, + int $scroll = 5, + mixed $validate = null, + string $hint = '' + ): int|string { + $this->suppressPromptSpacing(); + + return select( + label: $label, + options: $options, + default: $default, + scroll: $scroll, + validate: $validate, + hint: $hint + ); + } + + /** + * Prompt for multiple selections from options. + * + * @param string $label The question to display + * @param array $options Available options + * @param array $default Default selected option keys + * @param int $scroll Number of visible options + * @param bool $required Whether at least one selection is required + * @param mixed $validate Optional validation callback + * @param string $hint Optional hint text + * + * @return array The selected option keys + */ + protected function promptMultiselect( + string $label, + array $options, + array $default = [], + int $scroll = 5, + bool $required = false, + mixed $validate = null, + string $hint = '' + ): array { + $this->suppressPromptSpacing(); + + return multiselect( label: $label, + options: $options, + default: $default, + scroll: $scroll, + required: $required, + validate: $validate, + hint: $hint + ); + } + + /** + * Prompt with autocomplete suggestions. + * + * @param string $label The question to display + * @param array|Closure $options Available suggestions (array or closure) + * @param string $placeholder Optional placeholder text + * @param string $default Optional default value + * @param int $scroll Number of visible suggestions + * @param bool $required Whether input is required + * @param mixed $validate Optional validation callback + * @param string $hint Optional hint text + * + * @return string The user's input + */ + protected function promptSuggest( + string $label, + array|Closure $options, + string $placeholder = '', + string $default = '', + int $scroll = 5, + bool $required = true, + mixed $validate = null, + string $hint = '' + ): string { + $this->suppressPromptSpacing(); + + return suggest( + label: $label, + options: $options, placeholder: $placeholder, default: $default, - required: $required + scroll: $scroll, + required: $required, + validate: $validate, + hint: $hint + ); + } + + /** + * Prompt with searchable options. + * + * @param string $label The question to display + * @param Closure $options Closure that accepts search string and returns filtered options + * @param string $placeholder Optional placeholder text + * @param int $scroll Number of visible options + * @param mixed $validate Optional validation callback + * @param string $hint Optional hint text + * + * @return int|string The selected option key + */ + protected function promptSearch( + string $label, + Closure $options, + string $placeholder = '', + int $scroll = 5, + mixed $validate = null, + string $hint = '' + ): int|string { + $this->suppressPromptSpacing(); + + return search( + label: $label, + options: $options, + placeholder: $placeholder, + scroll: $scroll, + validate: $validate, + hint: $hint + ); + } + + /** + * Display a loading spinner during long operations. + * + * @template T + * + * @param Closure(): T $callback Operation to perform + * @param string $message Message to display + * + * @return T Result from the callback + */ + protected function promptSpin( + Closure $callback, + string $message = 'Loading...' + ): mixed { + return spin( + callback: $callback, + message: $message ); } } diff --git a/app/Traits/ConsoleOutputTrait.php b/app/Traits/ConsoleOutputTrait.php index 974f8dbe..403b1257 100644 --- a/app/Traits/ConsoleOutputTrait.php +++ b/app/Traits/ConsoleOutputTrait.php @@ -32,28 +32,12 @@ protected function writeln(string|array $lines): void // Message helpers // ------------------------------------------------------------------------------- - /** - * Display a plain text message. - */ - protected function text(string $message): void - { - $this->writeln($message); - } - /** * Display an info message with cyan info symbol. */ protected function info(string $message): void { - $this->writeln("ℹ {$message}"); - } - - /** - * Display a note message with cyan info symbol (alias for info). - */ - protected function note(string $message): void - { - $this->info($message); + $this->writeln("ℹ {$message}"); } /** @@ -61,7 +45,7 @@ protected function note(string $message): void */ protected function success(string $message): void { - $this->writeln("✓ {$message}"); + $this->writeln("✓ {$message}"); } /** @@ -69,21 +53,15 @@ protected function success(string $message): void */ protected function warning(string $message): void { - $this->writeln("⚠ {$message}"); + $this->writeln("⚠ {$message}"); } /** - * Display an error message with red X and optional tip. + * Display an error message with red X. */ - protected function error(string $message, ?string $tip = null): void + protected function error(string $message): void { - $output = ["✗ {$message}"]; - - if ($tip !== null) { - $output[] = "Tip: {$tip}"; - } - - $this->writeln($output); + $this->writeln("✗ {$message}"); } // @@ -95,7 +73,10 @@ protected function error(string $message, ?string $tip = null): void */ protected function h1(string $text): void { - $this->writeln(''.$text.''); + $this->writeln([ + ''.$text.'', + '', + ]); } /** @@ -110,27 +91,24 @@ protected function hr(): void } // - // Command Hints + // Command hint // ------------------------------------------------------------------------------- /** * Display a command replay hint showing how to run non-interactively. * * @param array $options Array of option name => value pairs - * @param array $provided Array of option name => was provided (true) or prompted (false) */ - protected function showCommandHint(string $commandName, array $options, array $provided): void + protected function showCommandHint(string $commandName, array $options): void { - $this->writeln('💡 Next time, run non-interactively:'); + $this->writeln('◆ Run non-interactively:'); $this->writeln(''); - // Build command parts - $parts = [$commandName]; + // + // Build command options + $parts = []; foreach ($options as $optionName => $value) { - $wasProvided = $provided[$optionName] ?? false; - $color = $wasProvided ? 'gray' : 'bright-yellow'; - if ($value === null || $value === '') { continue; } @@ -139,17 +117,23 @@ protected function showCommandHint(string $commandName, array $options, array $p $optionFlag = '--'.$optionName; if (is_bool($value)) { if ($value) { - $parts[] = "{$optionFlag}"; + $parts[] = $optionFlag; } } else { $stringValue = is_scalar($value) ? (string) $value : ''; $escapedValue = escapeshellarg($stringValue); - $parts[] = "{$optionFlag}={$escapedValue}"; + $parts[] = "{$optionFlag}={$escapedValue}"; } } - $command = implode(' ', $parts); - $this->writeln(" {$command}"); - $this->writeln(''); + // + // Display command hint + + $this->writeln(" vendor/bin/deployer {$commandName} \\ "); + + foreach ($parts as $index => $part) { + $last = $index === count($parts) - 1; + $this->writeln(" {$part}".($last ? '' : ' \\ ')); + } } } diff --git a/tests/Fixtures/TestConsoleCommand.php b/tests/Fixtures/TestConsoleCommand.php index 1f4614ed..983162cc 100644 --- a/tests/Fixtures/TestConsoleCommand.php +++ b/tests/Fixtures/TestConsoleCommand.php @@ -49,15 +49,14 @@ protected function configure(): void $this->setName('test-console')->setDescription('Test console trait methods'); $this->addOption('name', null, InputOption::VALUE_REQUIRED, 'Test name option'); $this->addOption('host', null, InputOption::VALUE_REQUIRED, 'Test host option'); + $this->addOption('yes', 'y', InputOption::VALUE_NONE, 'Test yes flag'); } protected function execute(InputInterface $input, OutputInterface $output): int { if ($this->methodToTest !== '') { match ($this->methodToTest) { - 'text' => $this->text(...$this->testArgs), 'info' => $this->info(...$this->testArgs), - 'note' => $this->note(...$this->testArgs), 'error' => $this->error(...$this->testArgs), 'success' => $this->success(...$this->testArgs), 'warning' => $this->warning(...$this->testArgs), @@ -66,6 +65,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'writeln' => $this->writeln(...$this->testArgs), 'showCommandHint' => $this->showCommandHint(...$this->testArgs), 'getOptionOrPrompt' => $this->testGetOptionOrPrompt(), + 'getOptionOrPromptEmpty' => $this->testGetOptionOrPromptEmpty(), + 'getOptionOrPromptBoolean' => $this->testGetOptionOrPromptBoolean(), + 'getOptionOrPromptTypes' => $this->testGetOptionOrPromptTypes(), + 'testPromptSpin' => $this->testPromptSpinWrapper(), + 'promptText' => $this->testPromptTextWrapper(), + 'promptPassword' => $this->testPromptPasswordWrapper(), + 'promptConfirm' => $this->testPromptConfirmWrapper(), + 'promptPause' => $this->testPromptPauseWrapper(), + 'promptSelect' => $this->testPromptSelectWrapper(), + 'promptMultiselect' => $this->testPromptMultiselectWrapper(), + 'promptSuggest' => $this->testPromptSuggestWrapper(), + 'promptSearch' => $this->testPromptSearchWrapper(), default => null, }; } @@ -78,8 +89,141 @@ protected function execute(InputInterface $input, OutputInterface $output): int */ private function testGetOptionOrPrompt(): void { - $wasProvided = false; - $result = $this->getOptionOrPrompt('name', 'Name:', wasProvided: $wasProvided); - $this->io->text("Result: {$result}, Provided: ".($wasProvided ? 'true' : 'false')); + $result = $this->getOptionOrPrompt( + 'name', + fn () => $this->promptText(label: 'Name:', required: true) + ); + $this->io->text("Result: {$result}"); + } + + /** + * Test getOptionOrPrompt with empty string handling. + */ + private function testGetOptionOrPromptEmpty(): void + { + $closureExecuted = false; + $result = $this->getOptionOrPrompt( + 'name', + function () use (&$closureExecuted) { + $closureExecuted = true; + + return 'from-closure'; + } + ); + + if ($closureExecuted) { + $this->io->text('Closure executed'); + } + $this->io->text("Result: {$result}"); + } + + /** + * Test getOptionOrPrompt with boolean flag. + */ + private function testGetOptionOrPromptBoolean(): void + { + $result = $this->getOptionOrPrompt( + 'yes', + fn () => false + ); + $this->io->text('Result: '.($result ? 'true' : 'false')); + } + + /** + * Test getOptionOrPrompt with different return types. + */ + private function testGetOptionOrPromptTypes(): void + { + $expected = $this->testArgs[0] ?? 'default'; + + $result = $this->getOptionOrPrompt( + 'name', + fn () => $expected + ); + + if (is_bool($result)) { + $this->io->text('Result: '.($result ? 'true' : 'false')); + } elseif (is_array($result)) { + $this->io->text('Result: '.json_encode($result)); + } else { + $this->io->text("Result: {$result}"); + } + } + + /** + * Test promptSpin wrapper. + */ + private function testPromptSpinWrapper(): void + { + $result = $this->promptSpin( + fn () => 'success', + 'Testing...' + ); + + $this->io->text("Spin result: {$result}"); + } + + /** + * Test promptText wrapper. + */ + private function testPromptTextWrapper(): void + { + $this->promptText('Test:', required: false); + } + + /** + * Test promptPassword wrapper. + */ + private function testPromptPasswordWrapper(): void + { + $this->promptPassword('Test:', required: false); + } + + /** + * Test promptConfirm wrapper. + */ + private function testPromptConfirmWrapper(): void + { + $this->promptConfirm('Test:'); + } + + /** + * Test promptPause wrapper. + */ + private function testPromptPauseWrapper(): void + { + $this->promptPause('Test'); + } + + /** + * Test promptSelect wrapper. + */ + private function testPromptSelectWrapper(): void + { + $this->promptSelect('Test:', ['a', 'b'], default: 'a'); + } + + /** + * Test promptMultiselect wrapper. + */ + private function testPromptMultiselectWrapper(): void + { + $this->promptMultiselect('Test:', ['a', 'b']); + } + + /** + * Test promptSuggest wrapper. + */ + private function testPromptSuggestWrapper(): void + { + $this->promptSuggest('Test:', ['a', 'b'], required: false); + } + + /** + * Test promptSearch wrapper. + */ + private function testPromptSearchWrapper(): void + { + $this->promptSearch('Test:', fn ($q) => ['a', 'b']); } } diff --git a/tests/TestHelpers.php b/tests/TestHelpers.php index d84e2531..13fc5030 100644 --- a/tests/TestHelpers.php +++ b/tests/TestHelpers.php @@ -2,12 +2,14 @@ declare(strict_types=1); +use Bigpixelrocket\DeployerPHP\Container; use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\FilesystemService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; use Bigpixelrocket\DeployerPHP\Services\ProcessFactory; use Bigpixelrocket\DeployerPHP\Services\VersionService; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand; use Symfony\Component\Dotenv\Dotenv; use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Filesystem\Filesystem; @@ -250,3 +252,22 @@ function mockServerRepository( return $repository; } } + +if (!function_exists('mockTestConsoleCommand')) { + /** + * Create a TestConsoleCommand for testing with mocked dependencies. + */ + function mockTestConsoleCommand( + bool $envFileExists = true, + string $envContent = 'API_KEY=test_value', + bool $inventoryFileExists = true, + array|string $inventoryData = '', + ): TestConsoleCommand { + $container = new Container(); + $env = mockEnvService($envFileExists, $envContent); + $inventory = mockInventoryService($inventoryFileExists, $inventoryData); + $servers = mockServerRepository($inventoryFileExists, $inventoryData); + + return new TestConsoleCommand($container, $env, $inventory, $servers); + } +} diff --git a/tests/Unit/Contracts/BaseCommandTest.php b/tests/Unit/Contracts/BaseCommandTest.php index af76f903..1ab6202e 100644 --- a/tests/Unit/Contracts/BaseCommandTest.php +++ b/tests/Unit/Contracts/BaseCommandTest.php @@ -41,7 +41,7 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $result = parent::execute($input, $output); - $this->text('Test command executed successfully'); + $this->writeln('Test command executed successfully'); return $result; } } diff --git a/tests/Unit/TestHelpersTest.php b/tests/Unit/TestHelpersTest.php index 97c3d5ce..3cb89dcb 100644 --- a/tests/Unit/TestHelpersTest.php +++ b/tests/Unit/TestHelpersTest.php @@ -169,6 +169,16 @@ }); }); +describe('mockTestConsoleCommand', function () { + it('creates TestConsoleCommand with mocked dependencies', function () { + // ACT + $command = mockTestConsoleCommand(); + + // ASSERT + expect($command)->toBeInstanceOf(\Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand::class); + }); +}); + describe('setEnv', function () { it('manages environment variables', function ($initialValue, $newValue, $expectSet) { // ARRANGE diff --git a/tests/Unit/Traits/ConsoleInputTraitTest.php b/tests/Unit/Traits/ConsoleInputTraitTest.php index ccc5fbf1..9e850fa3 100644 --- a/tests/Unit/Traits/ConsoleInputTraitTest.php +++ b/tests/Unit/Traits/ConsoleInputTraitTest.php @@ -4,23 +4,25 @@ namespace Bigpixelrocket\DeployerPHP\Tests\Unit\Traits; -use Bigpixelrocket\DeployerPHP\Container; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand; use Symfony\Component\Console\Tester\CommandTester; +use Throwable; require_once __DIR__.'/../../TestHelpers.php'; describe('ConsoleInputTrait', function () { beforeEach(function () { - $container = new Container(); - $this->command = new TestConsoleCommand($container, mockEnvService(true), mockInventoryService(true), mockServerRepository()); + $this->command = mockTestConsoleCommand(); $this->tester = new CommandTester($this->command); }); // // getOptionOrPrompt + // ------------------------------------------------------------------------------- - it('returns option value and sets wasProvided to true when option provided', function () { + // + // String Options + + it('returns option value when string option provided', function () { // ARRANGE $this->command->setTestMethod('getOptionOrPrompt'); @@ -29,7 +31,137 @@ $output = $this->tester->getDisplay(); // ASSERT - expect($output)->toContain('Result: production') - ->and($output)->toContain('Provided: true'); + expect($output)->toContain('Result: production'); + }); + + it('executes closure when string option is empty', function () { + // ARRANGE + $this->command->setTestMethod('getOptionOrPromptEmpty'); + + // ACT + $this->tester->execute(['--name' => '']); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Closure executed') + ->and($output)->toContain('Result: from-closure'); + }); + + it('executes closure when string option not provided', function () { + // ARRANGE + $this->command->setTestMethod('getOptionOrPromptEmpty'); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Closure executed') + ->and($output)->toContain('Result: from-closure'); + }); + + // + // Boolean Flags + + it('returns true when boolean flag is provided', function () { + // ARRANGE + $this->command->setTestMethod('getOptionOrPromptBoolean'); + + // ACT + $this->tester->execute(['--yes' => true]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Result: true'); + }); + + it('executes closure when boolean flag not provided', function () { + // ARRANGE + $this->command->setTestMethod('getOptionOrPromptBoolean'); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Result: false'); + }); + + // + // Return Type Flexibility + + it('supports different return types from closure', function (mixed $expected, string $description) { + // ARRANGE + $this->command->setTestMethod('getOptionOrPromptTypes', [$expected]); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + if (is_bool($expected)) { + expect($output)->toContain('Result: ' . ($expected ? 'true' : 'false')); + } elseif (is_array($expected)) { + expect($output)->toContain('Result: ' . json_encode($expected)); + } else { + expect($output)->toContain("Result: {$expected}"); + } + })->with([ + 'string return' => ['text-value', 'string'], + 'boolean true' => [true, 'boolean'], + 'boolean false' => [false, 'boolean'], + 'integer return' => [42, 'integer'], + 'array return' => [['option1', 'option2'], 'array'], + ]); + + // + // Prompt Wrappers + // ------------------------------------------------------------------------------- + + it('prompt wrappers suppress spacing with ANSI escape sequences', function (string $method) { + // ARRANGE + // Expected ANSI sequence: \033[1A (move up) + \033[2K (clear line) + $expectedAnsi = "\033[1A\033[2K"; + $this->command->setTestMethod($method); + + // ACT + // Capture raw output including ANSI sequences using output buffering + ob_start(); + + try { + // Execute command which calls the prompt wrapper + // It will output ANSI then fail on actual prompt (non-interactive mode) + $this->tester->execute([]); + } catch (Throwable) { + // Expected to fail in non-interactive mode, but ANSI was already output + } + + $output = ob_get_clean(); + + // ASSERT + // Verify the ANSI escape sequence was output for spacing suppression + expect($output)->toContain($expectedAnsi); + })->with([ + 'promptText', + 'promptPassword', + 'promptConfirm', + 'promptPause', + 'promptSelect', + 'promptMultiselect', + 'promptSuggest', + // Note: promptSearch is not tested here as it requires user interaction + // and cannot be tested in non-interactive mode even with default values + ]); + + it('promptSpin executes callback and returns result', function () { + // ARRANGE + $this->command->setTestMethod('testPromptSpin'); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Spin result: success'); }); }); diff --git a/tests/Unit/Traits/ConsoleOutputTraitTest.php b/tests/Unit/Traits/ConsoleOutputTraitTest.php index 87de3910..0e7f553b 100644 --- a/tests/Unit/Traits/ConsoleOutputTraitTest.php +++ b/tests/Unit/Traits/ConsoleOutputTraitTest.php @@ -4,119 +4,104 @@ namespace Bigpixelrocket\DeployerPHP\Tests\Unit\Traits; -use Bigpixelrocket\DeployerPHP\Container; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand; use Symfony\Component\Console\Tester\CommandTester; require_once __DIR__.'/../../TestHelpers.php'; describe('ConsoleOutputTrait', function () { beforeEach(function () { - $container = new Container(); - $this->command = new TestConsoleCommand($container, mockEnvService(true), mockInventoryService(true), mockServerRepository()); + $this->command = mockTestConsoleCommand(); $this->tester = new CommandTester($this->command); }); // - // Basic Output + // Raw output + // ------------------------------------------------------------------------------- - it('displays plain text message', function () { + it('writes single line', function () { // ARRANGE - $this->command->setTestMethod('text', ['Plain text message']); + $this->command->setTestMethod('writeln', ['Output line']); // ACT $this->tester->execute([]); $output = $this->tester->getDisplay(); // ASSERT - expect($output)->toContain('Plain text message'); + expect($output)->toContain('Output line'); }); - // - // Status Messages - - it('displays info message with cyan info symbol', function () { + it('writes multiple lines', function () { // ARRANGE - $this->command->setTestMethod('info', ['Information message']); + $this->command->setTestMethod('writeln', [['First line', 'Second line']]); // ACT $this->tester->execute([]); $output = $this->tester->getDisplay(); // ASSERT - expect($output)->toContain('ℹ') - ->and($output)->toContain('Information message'); + expect($output)->toContain('First line') + ->and($output)->toContain('Second line'); }); - it('displays note message with cyan info symbol', function () { - // ARRANGE - $this->command->setTestMethod('note', ['Note message']); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('ℹ') - ->and($output)->toContain('Note message'); - }); + // + // Message helpers + // ------------------------------------------------------------------------------- - it('displays error message with red X symbol', function () { + it('displays info message with cyan info symbol', function () { // ARRANGE - $this->command->setTestMethod('error', ['Connection failed']); + $this->command->setTestMethod('info', ['Information message']); // ACT $this->tester->execute([]); $output = $this->tester->getDisplay(); // ASSERT - expect($output)->toContain('✗') - ->and($output)->toContain('Connection failed'); + expect($output)->toContain('ℹ') + ->and($output)->toContain('Information message'); }); - it('displays error message with optional tip', function () { + it('displays success message with green checkmark', function () { // ARRANGE - $this->command->setTestMethod('error', ['Connection failed', 'Check your SSH key']); + $this->command->setTestMethod('success', ['Server added successfully']); // ACT $this->tester->execute([]); $output = $this->tester->getDisplay(); // ASSERT - expect($output)->toContain('✗') - ->and($output)->toContain('Connection failed') - ->and($output)->toContain('Tip:') - ->and($output)->toContain('Check your SSH key'); + expect($output)->toContain('✓') + ->and($output)->toContain('Server added successfully'); }); - it('displays success message with green checkmark', function () { + it('displays warning message with yellow warning symbol', function () { // ARRANGE - $this->command->setTestMethod('success', ['Server added successfully']); + $this->command->setTestMethod('warning', ['Skipping connection check']); // ACT $this->tester->execute([]); $output = $this->tester->getDisplay(); // ASSERT - expect($output)->toContain('✓') - ->and($output)->toContain('Server added successfully'); + expect($output)->toContain('⚠') + ->and($output)->toContain('Skipping connection check'); }); - it('displays warning message with yellow warning symbol', function () { + it('displays error message with red X symbol', function () { // ARRANGE - $this->command->setTestMethod('warning', ['Skipping connection check']); + $this->command->setTestMethod('error', ['Connection failed']); // ACT $this->tester->execute([]); $output = $this->tester->getDisplay(); // ASSERT - expect($output)->toContain('⚠') - ->and($output)->toContain('Skipping connection check'); + expect($output)->toContain('✗') + ->and($output)->toContain('Connection failed'); }); // - // Output Formatting + // Heading and separator + // ------------------------------------------------------------------------------- it('displays heading with icon', function () { // ARRANGE @@ -144,40 +129,15 @@ ->and(strlen($output))->toBeGreaterThan(40); }); - it('writes single line', function () { - // ARRANGE - $this->command->setTestMethod('writeln', ['Output line']); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('Output line'); - }); - - it('writes multiple lines', function () { - // ARRANGE - $this->command->setTestMethod('writeln', [['First line', 'Second line']]); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('First line') - ->and($output)->toContain('Second line'); - }); - // - // Command Hints + // Command hint + // ------------------------------------------------------------------------------- it('displays command hint for non-interactive execution', function () { // ARRANGE $this->command->setTestMethod('showCommandHint', [ 'server:add', ['name' => 'prod-server', 'host' => '192.168.1.100', 'yes' => true], - ['name' => false, 'host' => false, 'yes' => true], ]); // ACT @@ -185,19 +145,18 @@ $output = $this->tester->getDisplay(); // ASSERT - expect($output)->toContain('Next time, run non-interactively:') + expect($output)->toContain('Run non-interactively:') ->and($output)->toContain('server:add') ->and($output)->toContain('--name') ->and($output)->toContain('--host') ->and($output)->toContain('--yes'); }); - it('highlights prompted options differently in command hint', function () { + it('formats command options correctly in hint', function () { // ARRANGE $this->command->setTestMethod('showCommandHint', [ 'server:add', ['name' => 'prod-server', 'host' => '192.168.1.100'], - ['name' => true, 'host' => false], ]); // ACT @@ -216,7 +175,6 @@ $this->command->setTestMethod('showCommandHint', [ 'server:add', ['name' => 'prod-server', 'host' => null, 'port' => ''], - ['name' => true, 'host' => false, 'port' => false], ]); // ACT