diff --git a/app/Console/HelloCommand.php b/app/Console/HelloCommand.php index b99fc230..16ec8b25 100644 --- a/app/Console/HelloCommand.php +++ b/app/Console/HelloCommand.php @@ -22,7 +22,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $user = $this->env->get(['USER', 'USERNAME'], false) ?? 'there'; - $this->success('Hello ' . $user . '!'); + $this->io->success('Hello ' . $user . '!'); return Command::SUCCESS; } diff --git a/app/Console/Server/ServerAddCommand.php b/app/Console/Server/ServerAddCommand.php index 2c1b0b04..f5156430 100644 --- a/app/Console/Server/ServerAddCommand.php +++ b/app/Console/Server/ServerAddCommand.php @@ -51,17 +51,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->hr(); + $this->io->hr(); - $this->h1('Add New Server'); + $this->io->h1('Add New Server'); // // Gather server details /** @var string|null $name */ - $name = $this->getValidatedOptionOrPrompt( + $name = $this->io->getValidatedOptionOrPrompt( 'name', - fn ($validate) => $this->promptText( + fn ($validate) => $this->io->promptText( label: 'Server name:', placeholder: 'web1', required: true, @@ -75,9 +75,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int } /** @var string|null $host */ - $host = $this->getValidatedOptionOrPrompt( + $host = $this->io->getValidatedOptionOrPrompt( 'host', - fn ($validate) => $this->promptText( + fn ($validate) => $this->io->promptText( label: 'Host/IP address:', placeholder: '192.168.1.100', required: true, @@ -91,9 +91,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int } /** @var string|null $portString */ - $portString = $this->getValidatedOptionOrPrompt( + $portString = $this->io->getValidatedOptionOrPrompt( 'port', - fn ($validate) => $this->promptText( + fn ($validate) => $this->io->promptText( label: 'SSH port:', default: '22', required: true, @@ -109,9 +109,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $port = (int) $portString; /** @var string $username */ - $username = $this->getOptionOrPrompt( + $username = $this->io->getOptionOrPrompt( 'username', - fn (): string => $this->promptText( + fn (): string => $this->io->promptText( label: 'SSH username:', default: 'root', required: true @@ -119,9 +119,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); /** @var string $privateKeyPathRaw */ - $privateKeyPathRaw = $this->getOptionOrPrompt( + $privateKeyPathRaw = $this->io->getOptionOrPrompt( 'private-key-path', - fn (): string => $this->promptText( + fn (): string => $this->io->promptText( label: 'SSH private key path (leave empty for default ~/.ssh/id_ed25519 or ~/.ssh/id_rsa):', default: '', required: false @@ -142,7 +142,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int privateKeyPath: $privateKeyPath ); - $this->hr(); + $this->io->hr(); $this->displayServerDeets($server); @@ -150,17 +150,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Verify connectivity /** @var bool $skipCheck */ - $skipCheck = $this->getOptionOrPrompt( + $skipCheck = $this->io->getOptionOrPrompt( 'skip', - fn (): bool => !$this->promptConfirm( + fn (): bool => !$this->io->promptConfirm( label: 'Test SSH connection before saving?', default: true ) ); if ($skipCheck) { - $this->warning('Skipping SSH connection check'); - $this->writeln(''); + $this->io->warning('Skipping SSH connection check'); + $this->io->writeln(''); } else { if (!$this->testConnection($server)) { return Command::FAILURE; @@ -171,17 +171,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Confirm creation /** @var bool $confirmed */ - $confirmed = $this->getOptionOrPrompt( + $confirmed = $this->io->getOptionOrPrompt( 'yes', - fn (): bool => $this->promptConfirm( + fn (): bool => $this->io->promptConfirm( label: 'Save this server to inventory?', default: true ) ); if (!$confirmed) { - $this->warning('Cancelled adding server'); - $this->writeln(''); + $this->io->warning('Cancelled adding server'); + $this->io->writeln(''); return Command::SUCCESS; } @@ -192,18 +192,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $this->servers->create($server); } catch (\RuntimeException $e) { - $this->error('Failed to add server: ' . $e->getMessage()); + $this->io->error('Failed to add server: ' . $e->getMessage()); return Command::FAILURE; } - $this->success('Server added successfully'); - $this->writeln(''); + $this->io->success('Server added successfully'); + $this->io->writeln(''); // // Show command hint - $this->showCommandHint('server:add', [ + $this->io->showCommandHint('server:add', [ 'name' => $name, 'host' => $host, 'port' => $port, @@ -226,7 +226,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int private function testConnection(ServerDTO $server): bool { try { - $this->promptSpin( + $this->io->promptSpin( callback: fn () => $this->ssh->assertCanConnect( $server->host, $server->port, @@ -236,13 +236,13 @@ private function testConnection(ServerDTO $server): bool message: 'Connecting to server...' ); - $this->success('SSH connection successful'); + $this->io->success('SSH connection successful'); return true; } catch (\RuntimeException $e) { - $this->error($e->getMessage()); + $this->io->error($e->getMessage()); - $this->writeln([ + $this->io->writeln([ '', ' Common issues:', '', diff --git a/app/Console/Server/ServerDeleteCommand.php b/app/Console/Server/ServerDeleteCommand.php index cbaa6ab8..a3a6ff24 100644 --- a/app/Console/Server/ServerDeleteCommand.php +++ b/app/Console/Server/ServerDeleteCommand.php @@ -41,9 +41,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->hr(); + $this->io->hr(); - $this->h1('Delete Server'); + $this->io->h1('Delete Server'); // // Select server @@ -61,17 +61,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Confirm deletion /** @var bool $confirmed */ - $confirmed = $this->getOptionOrPrompt( + $confirmed = $this->io->getOptionOrPrompt( 'yes', - fn (): bool => $this->promptConfirm( + fn (): bool => $this->io->promptConfirm( label: 'Are you sure you want to delete this server?', default: true ) ); if (!$confirmed) { - $this->warning('Cancelled deleting server'); - $this->writeln(''); + $this->io->warning('Cancelled deleting server'); + $this->io->writeln(''); return Command::SUCCESS; } @@ -81,13 +81,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->servers->delete($server->name); - $this->success("Server '{$server->name}' deleted successfully"); - $this->writeln(''); + $this->io->success("Server '{$server->name}' deleted successfully"); + $this->io->writeln(''); // // Show command hint - $this->showCommandHint('server:delete', [ + $this->io->showCommandHint('server:delete', [ 'server' => $server->name, 'yes' => $confirmed, ]); diff --git a/app/Console/Server/ServerListCommand.php b/app/Console/Server/ServerListCommand.php index 80923063..dde14638 100644 --- a/app/Console/Server/ServerListCommand.php +++ b/app/Console/Server/ServerListCommand.php @@ -27,15 +27,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int { parent::execute($input, $output); - $this->hr(); + $this->io->hr(); // // Get all servers $allServers = $this->servers->all(); if (count($allServers) === 0) { - $this->warning('No servers found in inventory'); - $this->writeln([ + $this->io->warning('No servers found in inventory'); + $this->io->writeln([ '', 'Use server:add to add a server', '', @@ -44,7 +44,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - $this->h1('All Servers'); + $this->io->h1('All Servers'); foreach ($allServers as $server) { $this->displayServerDeets($server); diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php index e6cf3550..5a20519b 100644 --- a/app/Contracts/BaseCommand.php +++ b/app/Contracts/BaseCommand.php @@ -9,37 +9,27 @@ use Bigpixelrocket\DeployerPHP\Repositories\SiteRepository; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use Bigpixelrocket\DeployerPHP\Services\IOService; use Bigpixelrocket\DeployerPHP\Services\ProcessService; -use Bigpixelrocket\DeployerPHP\Services\PrompterService; use Bigpixelrocket\DeployerPHP\Services\SSHService; -use Bigpixelrocket\DeployerPHP\Traits\ConsoleInputTrait; -use Bigpixelrocket\DeployerPHP\Traits\ConsoleOutputTrait; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; /** * Base command with shared functionality for all commands. * - * Uses ConsoleInputTrait for input gathering and ConsoleOutputTrait - * for formatted output. All console commands should extend this class. + * Uses IOService for all console input/output operations. + * All console commands should extend this class. */ abstract class BaseCommand extends Command { - use ConsoleInputTrait; - use ConsoleOutputTrait; - - protected InputInterface $input; - protected OutputInterface $output; - protected SymfonyStyle $io; - /** * Create a new BaseCommand with the application's services and repositories. * - * The constructor accepts and stores dependencies (environment and inventory services, - * process and prompting helpers, server/site repositories, SSH service, and the DI container) + * The constructor accepts and stores dependencies (I/O service, environment and inventory services, + * process service, server/site repositories, SSH service, and the DI container) * used by this command and its subclasses. */ public function __construct( @@ -49,8 +39,8 @@ public function __construct( // Base services protected readonly EnvService $env, protected readonly InventoryService $inventory, + protected readonly IOService $io, protected readonly ProcessService $proc, - protected readonly PrompterService $prompter, // Servers & sites protected readonly ServerRepository $servers, @@ -89,10 +79,9 @@ protected function configure(): void /** * Prepare console IO and initialize environment, inventory, and repositories. * - * Sets the command's input/output properties, creates a SymfonyStyle IO helper, - * applies any custom paths provided via the `--env` and `--inventory` options, - * loads the corresponding files, and populates the servers and sites repositories - * from the loaded inventory. + * Initializes the I/O service with command context, applies any custom paths provided + * via the `--env` and `--inventory` options, loads the corresponding files, and populates + * the servers and sites repositories from the loaded inventory. * * @param InputInterface $input The current console input. * @param OutputInterface $output The current console output. @@ -101,9 +90,10 @@ protected function initialize(InputInterface $input, OutputInterface $output): v { parent::initialize($input, $output); - $this->input = $input; - $this->output = $output; - $this->io = new SymfonyStyle($input, $output); + // + // Initialize I/O service + + $this->io->initialize($this, $input, $output); // // Initialize env service @@ -142,14 +132,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int $envStatus = $this->env->getEnvFileStatus(); $color = str_starts_with($envStatus, 'No .env') ? 'yellow' : 'gray'; - $this->writeln([ + $this->io->writeln([ ' Environment: ', " {$envStatus}", '', ]); $inventoryStatus = $this->inventory->getInventoryFileStatus(); - $this->writeln([ + $this->io->writeln([ ' Inventory: ', ' '.$inventoryStatus.'', '', diff --git a/app/Traits/ConsoleInputTrait.php b/app/Services/IOService.php similarity index 61% rename from app/Traits/ConsoleInputTrait.php rename to app/Services/IOService.php index 29236d8d..105336f1 100644 --- a/app/Traits/ConsoleInputTrait.php +++ b/app/Services/IOService.php @@ -2,20 +2,53 @@ declare(strict_types=1); -namespace Bigpixelrocket\DeployerPHP\Traits; +namespace Bigpixelrocket\DeployerPHP\Services; 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; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + /** - * Console input gathering helpers. + * Console I/O service. * - * Requires the using class to have: - * - `protected InputInterface $input` property - * - `protected PrompterService $prompter` property - * - `getDefinition()` method (typically from extending Command) + * Handles all console input/output operations including prompts, output formatting, + * and status messages. Must be initialized with a command context before use. */ -trait ConsoleInputTrait +class IOService { + private Command $command; + private InputInterface $input; + private SymfonyStyle $io; + + /** + * Initialize the I/O service with command context. + * + * Must be called before using any I/O methods. + */ + public function initialize(Command $command, InputInterface $input, OutputInterface $output): void + { + $this->command = $command; // Used to inspect input definitions (like --yes and -y, etc.) + $this->input = $input; + $this->io = new SymfonyStyle($input, $output); + } + + // + // Input Gathering + // ------------------------------------------------------------------------------- + /** * Get option value or prompt user interactively. * @@ -31,24 +64,24 @@ trait ConsoleInputTrait * * @example * // Text input - * $name = $this->getOptionOrPrompt( + * $name = $this->io->getOptionOrPrompt( * 'name', - * fn() => text('Server name:', placeholder: 'web1') + * fn() => $this->io->promptText('Server name:', placeholder: 'web1') * ); * * // Boolean flag (VALUE_NONE option) - * $skip = $this->getOptionOrPrompt( + * $skip = $this->io->getOptionOrPrompt( * 'skip', - * fn() => confirm('Skip verification?', default: false) + * fn() => $this->io->promptConfirm('Skip verification?', default: false) * ); * * // Select input - * $env = $this->getOptionOrPrompt( + * $env = $this->io->getOptionOrPrompt( * 'environment', - * fn() => select('Environment:', ['dev', 'staging', 'prod']) + * fn() => $this->io->promptSelect('Environment:', ['dev', 'staging', 'prod']) * ); */ - protected function getOptionOrPrompt( + public function getOptionOrPrompt( string $optionName, Closure $promptCallback ): mixed { @@ -61,7 +94,7 @@ protected function getOptionOrPrompt( // Try to find short flag from option definition try { - $inputDef = $this->getDefinition(); + $inputDef = $this->command->getDefinition(); if ($inputDef->hasOption($optionName)) { $option = $inputDef->getOption($optionName); if ($option->getShortcut() !== null) { @@ -111,9 +144,9 @@ protected function getOptionOrPrompt( * @return mixed The validated value, or null if validation failed * * @example - * $name = $this->getValidatedOptionOrPrompt( + * $name = $this->io->getValidatedOptionOrPrompt( * 'name', - * fn($validate) => $this->promptText( + * fn($validate) => $this->io->promptText( * label: 'Server name:', * validate: $validate * ), @@ -123,7 +156,7 @@ protected function getOptionOrPrompt( * return Command::FAILURE; * } */ - protected function getValidatedOptionOrPrompt( + public function getValidatedOptionOrPrompt( string $optionName, Closure $promptCallback, Closure $validator @@ -163,7 +196,7 @@ protected function getValidatedOptionOrPrompt( * * @return string The user's input */ - protected function promptText( + public function promptText( string $label, string $placeholder = '', string $default = '', @@ -171,7 +204,9 @@ protected function promptText( mixed $validate = null, string $hint = '' ): string { - return $this->prompter->text( + $this->suppressPromptSpacing(); + + return text( label: $label, placeholder: $placeholder, default: $default, @@ -192,14 +227,16 @@ protected function promptText( * * @return string The user's password input */ - protected function promptPassword( + public function promptPassword( string $label, string $placeholder = '', bool $required = true, mixed $validate = null, string $hint = '' ): string { - return $this->prompter->password( + $this->suppressPromptSpacing(); + + return password( label: $label, placeholder: $placeholder, required: $required, @@ -219,14 +256,16 @@ protected function promptPassword( * * @return bool True if confirmed, false otherwise */ - protected function promptConfirm( + public function promptConfirm( string $label, bool $default = true, string $yes = 'Yes', string $no = 'No', string $hint = '' ): bool { - return $this->prompter->confirm( + $this->suppressPromptSpacing(); + + return confirm( label: $label, default: $default, yes: $yes, @@ -242,9 +281,11 @@ protected function promptConfirm( * * @return bool Always returns a boolean */ - protected function promptPause(string $message = 'Press enter to continue...'): bool + public function promptPause(string $message = 'Press enter to continue...'): bool { - return $this->prompter->pause($message); + $this->suppressPromptSpacing(); + + return pause($message); } /** @@ -259,7 +300,7 @@ protected function promptPause(string $message = 'Press enter to continue...'): * * @return int|string The selected option key */ - protected function promptSelect( + public function promptSelect( string $label, array $options, int|string|null $default = null, @@ -267,7 +308,9 @@ protected function promptSelect( mixed $validate = null, string $hint = '' ): int|string { - return $this->prompter->select( + $this->suppressPromptSpacing(); + + return select( label: $label, options: $options, default: $default, @@ -290,7 +333,7 @@ protected function promptSelect( * * @return array The selected option keys */ - protected function promptMultiselect( + public function promptMultiselect( string $label, array $options, array $default = [], @@ -299,7 +342,9 @@ protected function promptMultiselect( mixed $validate = null, string $hint = '' ): array { - return $this->prompter->multiselect( + $this->suppressPromptSpacing(); + + return multiselect( label: $label, options: $options, default: $default, @@ -324,7 +369,7 @@ protected function promptMultiselect( * * @return string The user's input */ - protected function promptSuggest( + public function promptSuggest( string $label, array|Closure $options, string $placeholder = '', @@ -334,7 +379,9 @@ protected function promptSuggest( mixed $validate = null, string $hint = '' ): string { - return $this->prompter->suggest( + $this->suppressPromptSpacing(); + + return suggest( label: $label, options: $options, placeholder: $placeholder, @@ -358,7 +405,7 @@ protected function promptSuggest( * * @return int|string The selected option key */ - protected function promptSearch( + public function promptSearch( string $label, Closure $options, string $placeholder = '', @@ -366,7 +413,9 @@ protected function promptSearch( mixed $validate = null, string $hint = '' ): int|string { - return $this->prompter->search( + $this->suppressPromptSpacing(); + + return search( label: $label, options: $options, placeholder: $placeholder, @@ -386,13 +435,143 @@ protected function promptSearch( * * @return T Result from the callback */ - protected function promptSpin( + public function promptSpin( Closure $callback, string $message = 'Loading...' ): mixed { - return $this->prompter->spin( + return spin( callback: $callback, message: $message ); } + + // + // Output Methods + // ------------------------------------------------------------------------------- + + /** + * Write-out multiple lines. + * + * @param array $lines + */ + public function writeln(string|array $lines): void + { + $writeLines = is_array($lines) ? $lines : [$lines]; + foreach ($writeLines as $line) { + $this->io->writeln(' '.$line); + } + } + + /** + * Display an info message with cyan info symbol. + */ + public function info(string $message): void + { + $this->writeln("ℹ {$message}"); + } + + /** + * Display a success message with green checkmark. + */ + public function success(string $message): void + { + $this->writeln("✓ {$message}"); + } + + /** + * Display a warning message with yellow warning symbol. + */ + public function warning(string $message): void + { + $this->writeln("⚠ {$message}"); + } + + /** + * Display an error message with red X. + */ + public function error(string $message): void + { + $this->writeln("✗ {$message}"); + } + + /** + * Write-out a heading. + */ + public function h1(string $text): void + { + $this->writeln([ + ''.$text.'', + '', + ]); + } + + /** + * Write-out a separator line. + */ + public function hr(): void + { + $this->writeln([ + '╭───────────────────────────────────────────────', + '', + ]); + } + + /** + * Display a command replay hint showing how to run non-interactively. + * + * @param array $options Array of option name => value pairs + */ + public function showCommandHint(string $commandName, array $options): void + { + $this->writeln('◆ Run non-interactively:'); + $this->writeln(''); + + // + // Build command options + + $parts = []; + foreach ($options as $optionName => $value) { + if ($value === null || $value === '') { + continue; + } + + // Format the option + $optionFlag = '--'.$optionName; + if (is_bool($value)) { + if ($value) { + $parts[] = $optionFlag; + } + } else { + $stringValue = is_scalar($value) ? (string) $value : ''; + $escapedValue = escapeshellarg($stringValue); + $parts[] = "{$optionFlag}={$escapedValue}"; + } + } + + // + // Display command hint + + $this->writeln(" vendor/bin/deployer {$commandName} \\ "); + + foreach ($parts as $index => $part) { + $last = $index === count($parts) - 1; + $this->writeln(" {$part}".($last ? '' : ' \\ ')); + } + } + + // + // Private Helpers + // ------------------------------------------------------------------------------- + + /** + * 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"; + } } diff --git a/app/Services/PrompterService.php b/app/Services/PrompterService.php deleted file mode 100644 index 9f5e4b7b..00000000 --- a/app/Services/PrompterService.php +++ /dev/null @@ -1,246 +0,0 @@ -suppressPromptSpacing(); - - return text( - label: $label, - placeholder: $placeholder, - default: $default, - required: $required, - validate: $validate, - hint: $hint - ); - } - - /** - * Prompt for password input. - */ - public function password( - string $label, - string $placeholder = '', - bool $required = true, - mixed $validate = null, - string $hint = '' - ): string { - $this->suppressPromptSpacing(); - - return password( - label: $label, - placeholder: $placeholder, - required: $required, - validate: $validate, - hint: $hint - ); - } - - /** - * Prompt for yes/no confirmation. - */ - public function confirm( - string $label, - bool $default = true, - string $yes = 'Yes', - string $no = 'No', - string $hint = '' - ): bool { - $this->suppressPromptSpacing(); - - return confirm( - label: $label, - default: $default, - yes: $yes, - no: $no, - hint: $hint - ); - } - - /** - * Display a message and wait for user to press Enter. - */ - public function pause(string $message = 'Press enter to continue...'): bool - { - $this->suppressPromptSpacing(); - - return pause($message); - } - - /** - * Prompt for single selection from options. - * - * @param array $options - */ - public function select( - 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 array $options - * @param array $default - * - * @return array - */ - public function multiselect( - 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 array|Closure $options - */ - public function suggest( - 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, - scroll: $scroll, - required: $required, - validate: $validate, - hint: $hint - ); - } - - /** - * Prompt with searchable options. - */ - public function search( - 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 - * - * @return T - */ - public function spin( - Closure $callback, - string $message = 'Loading...' - ): mixed { - return spin( - callback: $callback, - message: $message - ); - } - - // - // Private Helpers - // ------------------------------------------------------------------------------- - - /** - * 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"; - } -} diff --git a/app/Traits/ConsoleOutputTrait.php b/app/Traits/ConsoleOutputTrait.php deleted file mode 100644 index ef675d67..00000000 --- a/app/Traits/ConsoleOutputTrait.php +++ /dev/null @@ -1,139 +0,0 @@ - $lines - */ - protected function writeln(string|array $lines): void - { - $writeLines = is_array($lines) ? $lines : [$lines]; - foreach ($writeLines as $line) { - $this->io->writeln(' '.$line); - } - } - - // - // Message helpers - // ------------------------------------------------------------------------------- - - /** - * Display an info message with cyan info symbol. - */ - protected function info(string $message): void - { - $this->writeln("ℹ {$message}"); - } - - /** - * Display a success message with green checkmark. - */ - protected function success(string $message): void - { - $this->writeln("✓ {$message}"); - } - - /** - * Display a warning message with yellow warning symbol. - */ - protected function warning(string $message): void - { - $this->writeln("⚠ {$message}"); - } - - /** - * Display an error message with red X. - */ - protected function error(string $message): void - { - $this->writeln("✗ {$message}"); - } - - // - // Heading and separator - // ------------------------------------------------------------------------------- - - /** - * Write-out a heading. - */ - protected function h1(string $text): void - { - $this->writeln([ - ''.$text.'', - '', - ]); - } - - /** - * Write-out a separator line. - */ - protected function hr(): void - { - $this->writeln([ - '╭───────────────────────────────────────────────', - '', - ]); - } - - // - // Command hint - // ------------------------------------------------------------------------------- - - /** - * Display a command replay hint showing how to run non-interactively. - * - * @param array $options Array of option name => value pairs - */ - protected function showCommandHint(string $commandName, array $options): void - { - $this->writeln('◆ Run non-interactively:'); - $this->writeln(''); - - // - // Build command options - - $parts = []; - foreach ($options as $optionName => $value) { - if ($value === null || $value === '') { - continue; - } - - // Format the option - $optionFlag = '--'.$optionName; - if (is_bool($value)) { - if ($value) { - $parts[] = $optionFlag; - } - } else { - $stringValue = is_scalar($value) ? (string) $value : ''; - $escapedValue = escapeshellarg($stringValue); - $parts[] = "{$optionFlag}={$escapedValue}"; - } - } - - // - // 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/app/Traits/ServerHelpersTrait.php b/app/Traits/ServerHelpersTrait.php index ca8d9b82..dd7eafc8 100644 --- a/app/Traits/ServerHelpersTrait.php +++ b/app/Traits/ServerHelpersTrait.php @@ -12,7 +12,8 @@ /** * Reusable server-related helpers for commands. * - * Requires the using class to use the ConsoleOutputTrait and have: + * Requires the using class to have: + * - protected IOService $io * - protected ServerRepository $servers * - protected SSHService $ssh */ @@ -30,8 +31,8 @@ protected function selectServer(string $optionName = 'server', string $promptLab $allServers = $this->servers->all(); if (count($allServers) === 0) { - $this->warning('No servers found in inventory'); - $this->writeln([ + $this->io->warning('No servers found in inventory'); + $this->io->writeln([ '', 'Use server:add to add a server', '', @@ -45,9 +46,9 @@ protected function selectServer(string $optionName = 'server', string $promptLab $serverNames = array_map(fn (ServerDTO $server) => $server->name, $allServers); - $name = (string) $this->getOptionOrPrompt( + $name = (string) $this->io->getOptionOrPrompt( $optionName, - fn () => $this->promptSelect( + fn () => $this->io->promptSelect( label: $promptLabel, options: $serverNames, ) @@ -59,7 +60,7 @@ protected function selectServer(string $optionName = 'server', string $promptLab $server = $this->servers->findByName($name); if ($server === null) { - $this->error("Server '{$name}' not found in inventory"); + $this->io->error("Server '{$name}' not found in inventory"); return ['server' => null, 'exit_code' => Command::FAILURE]; } @@ -72,7 +73,7 @@ protected function selectServer(string $optionName = 'server', string $promptLab */ protected function displayServerDeets(ServerDTO $server): void { - $this->writeln([ + $this->io->writeln([ " Name: {$server->name}", " Host: {$server->host}", " Port: {$server->port}", diff --git a/tests/Fixtures/MockPrompter.php b/tests/Fixtures/MockPrompter.php deleted file mode 100644 index 9716de62..00000000 --- a/tests/Fixtures/MockPrompter.php +++ /dev/null @@ -1,202 +0,0 @@ - $textQueue - * @param array $passwordQueue - * @param array $confirmQueue - * @param array $selectQueue - * @param array> $multiselectQueue - * @param array $suggestQueue - * @param array $searchQueue - * @param array $pauseQueue - */ - public function __construct(private array $textQueue = [], private array $passwordQueue = [], private array $confirmQueue = [], private array $selectQueue = [], private array $multiselectQueue = [], private array $suggestQueue = [], private array $searchQueue = [], private array $pauseQueue = []) - { - } - - // - // Prompt Methods - // ------------------------------------------------------------------------------- - - /** - * Return next text value from queue. - */ - public function text( - string $label, - string $placeholder = '', - string $default = '', - bool $required = true, - mixed $validate = null, - string $hint = '' - ): string { - if (empty($this->textQueue)) { - throw new \RuntimeException('MockPrompter: No text values left in queue for prompt: ' . $label); - } - - return array_shift($this->textQueue); - } - - /** - * Return next password value from queue. - */ - public function password( - string $label, - string $placeholder = '', - bool $required = true, - mixed $validate = null, - string $hint = '' - ): string { - if (empty($this->passwordQueue)) { - throw new \RuntimeException('MockPrompter: No password values left in queue for prompt: ' . $label); - } - - return array_shift($this->passwordQueue); - } - - /** - * Return next confirm value from queue. - */ - public function confirm( - string $label, - bool $default = true, - string $yes = 'Yes', - string $no = 'No', - string $hint = '' - ): bool { - if (empty($this->confirmQueue)) { - throw new \RuntimeException('MockPrompter: No confirm values left in queue for prompt: ' . $label); - } - - return array_shift($this->confirmQueue); - } - - /** - * Return next pause value from queue. - */ - public function pause(string $message = 'Press enter to continue...'): bool - { - if (empty($this->pauseQueue)) { - throw new \RuntimeException('MockPrompter: No pause values left in queue'); - } - - return array_shift($this->pauseQueue); - } - - /** - * Return next select value from queue. - * - * @param array $options - */ - public function select( - string $label, - array $options, - int|string|null $default = null, - int $scroll = 5, - mixed $validate = null, - string $hint = '' - ): int|string { - if (empty($this->selectQueue)) { - throw new \RuntimeException('MockPrompter: No select values left in queue for prompt: ' . $label); - } - - return array_shift($this->selectQueue); - } - - /** - * Return next multiselect value from queue. - * - * @param array $options - * @param array $default - * - * @return array - */ - public function multiselect( - string $label, - array $options, - array $default = [], - int $scroll = 5, - bool $required = false, - mixed $validate = null, - string $hint = '' - ): array { - if (empty($this->multiselectQueue)) { - throw new \RuntimeException('MockPrompter: No multiselect values left in queue for prompt: ' . $label); - } - - return array_shift($this->multiselectQueue); - } - - /** - * Return next suggest value from queue. - * - * @param array|Closure $options - */ - public function suggest( - string $label, - array|Closure $options, - string $placeholder = '', - string $default = '', - int $scroll = 5, - bool $required = true, - mixed $validate = null, - string $hint = '' - ): string { - if (empty($this->suggestQueue)) { - throw new \RuntimeException('MockPrompter: No suggest values left in queue for prompt: ' . $label); - } - - return array_shift($this->suggestQueue); - } - - /** - * Return next search value from queue. - */ - public function search( - string $label, - Closure $options, - string $placeholder = '', - int $scroll = 5, - mixed $validate = null, - string $hint = '' - ): int|string { - if (empty($this->searchQueue)) { - throw new \RuntimeException('MockPrompter: No search values left in queue for prompt: ' . $label); - } - - return array_shift($this->searchQueue); - } - - /** - * Execute callback and return result (no spinner shown in tests). - * - * @template T - * - * @param Closure(): T $callback - * - * @return T - */ - public function spin( - Closure $callback, - string $message = 'Loading...' - ): mixed { - // In tests, just execute the callback without the spinner - return $callback(); - } -} diff --git a/tests/Fixtures/TestConsoleCommand.php b/tests/Fixtures/TestConsoleCommand.php index c3066af0..4563ad4d 100644 --- a/tests/Fixtures/TestConsoleCommand.php +++ b/tests/Fixtures/TestConsoleCommand.php @@ -10,8 +10,8 @@ use Bigpixelrocket\DeployerPHP\Repositories\SiteRepository; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use Bigpixelrocket\DeployerPHP\Services\IOService; use Bigpixelrocket\DeployerPHP\Services\ProcessService; -use Bigpixelrocket\DeployerPHP\Services\PrompterService; use Bigpixelrocket\DeployerPHP\Services\SSHService; use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait; use Symfony\Component\Console\Command\Command; @@ -20,9 +20,9 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * Test fixture for BaseCommand trait testing. + * Test fixture for BaseCommand testing. * - * Supports testing both ConsoleInputTrait, ConsoleOutputTrait, and ServerHelpersTrait methods. + * Supports testing IOService and ServerHelpersTrait methods. */ class TestConsoleCommand extends BaseCommand { @@ -37,8 +37,8 @@ class TestConsoleCommand extends BaseCommand * @param Container $container Dependency injection container. * @param EnvService $env Environment service. * @param InventoryService $inventory Inventory management service. + * @param IOService $io I/O service for console operations. * @param ProcessService $proc Process execution service. - * @param PrompterService $prompter Interactive prompt service. * @param ServerRepository $servers Repository for server records. * @param SiteRepository $sites Repository for site records. * @param SSHService $ssh SSH service for remote execution. @@ -47,13 +47,13 @@ public function __construct( Container $container, EnvService $env, InventoryService $inventory, + IOService $io, ProcessService $proc, - PrompterService $prompter, ServerRepository $servers, SiteRepository $sites, SSHService $ssh, ) { - parent::__construct($container, $env, $inventory, $proc, $prompter, $servers, $sites, $ssh); + parent::__construct($container, $env, $inventory, $io, $proc, $servers, $sites, $ssh); } /** @@ -78,14 +78,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int { if ($this->methodToTest !== '') { match ($this->methodToTest) { - 'info' => $this->info(...$this->testArgs), - 'error' => $this->error(...$this->testArgs), - 'success' => $this->success(...$this->testArgs), - 'warning' => $this->warning(...$this->testArgs), - 'h1' => $this->h1(...$this->testArgs), - 'hr' => $this->hr(), - 'writeln' => $this->writeln(...$this->testArgs), - 'showCommandHint' => $this->showCommandHint(...$this->testArgs), + 'info' => $this->io->info(...$this->testArgs), + 'error' => $this->io->error(...$this->testArgs), + 'success' => $this->io->success(...$this->testArgs), + 'warning' => $this->io->warning(...$this->testArgs), + 'h1' => $this->io->h1(...$this->testArgs), + 'hr' => $this->io->hr(), + 'writeln' => $this->io->writeln(...$this->testArgs), + 'showCommandHint' => $this->io->showCommandHint(...$this->testArgs), 'displayServerDeets' => $this->displayServerDeets(...$this->testArgs), 'getOptionOrPrompt' => $this->testGetOptionOrPrompt(), 'getOptionOrPromptEmpty' => $this->testGetOptionOrPromptEmpty(), @@ -114,11 +114,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int */ private function testGetOptionOrPrompt(): void { - $result = $this->getOptionOrPrompt( + $result = $this->io->getOptionOrPrompt( 'name', - fn () => $this->promptText(label: 'Name:', required: true) + fn () => $this->io->promptText(label: 'Name:', required: true) ); - $this->io->text("Result: {$result}"); + $this->io->writeln("Result: {$result}"); } /** @@ -127,7 +127,7 @@ private function testGetOptionOrPrompt(): void private function testGetOptionOrPromptEmpty(): void { $closureExecuted = false; - $result = $this->getOptionOrPrompt( + $result = $this->io->getOptionOrPrompt( 'name', function () use (&$closureExecuted) { $closureExecuted = true; @@ -137,9 +137,9 @@ function () use (&$closureExecuted) { ); if ($closureExecuted) { - $this->io->text('Closure executed'); + $this->io->writeln('Closure executed'); } - $this->io->text("Result: {$result}"); + $this->io->writeln("Result: {$result}"); } /** @@ -147,11 +147,11 @@ function () use (&$closureExecuted) { */ private function testGetOptionOrPromptBoolean(): void { - $result = $this->getOptionOrPrompt( + $result = $this->io->getOptionOrPrompt( 'yes', fn () => false ); - $this->io->text('Result: '.($result ? 'true' : 'false')); + $this->io->writeln('Result: '.($result ? 'true' : 'false')); } /** @@ -161,17 +161,17 @@ private function testGetOptionOrPromptTypes(): void { $expected = $this->testArgs[0] ?? 'default'; - $result = $this->getOptionOrPrompt( + $result = $this->io->getOptionOrPrompt( 'name', fn () => $expected ); if (is_bool($result)) { - $this->io->text('Result: '.($result ? 'true' : 'false')); + $this->io->writeln('Result: '.($result ? 'true' : 'false')); } elseif (is_array($result)) { - $this->io->text('Result: '.json_encode($result)); + $this->io->writeln('Result: '.json_encode($result)); } else { - $this->io->text("Result: {$result}"); + $this->io->writeln("Result: {$result}"); } } @@ -180,16 +180,16 @@ private function testGetOptionOrPromptTypes(): void */ private function testGetValidatedOptionOrPromptValid(): void { - $result = $this->getValidatedOptionOrPrompt( + $result = $this->io->getValidatedOptionOrPrompt( 'name', - fn ($validate) => $this->promptText(label: 'Name:', validate: $validate), + fn ($validate) => $this->io->promptText(label: 'Name:', validate: $validate), fn ($value) => trim((string) $value) === '' ? 'Cannot be empty' : null ); if ($result === null) { - $this->io->text('Result: null'); + $this->io->writeln('Result: null'); } else { - $this->io->text("Result: {$result}"); + $this->io->writeln("Result: {$result}"); } } @@ -198,13 +198,13 @@ private function testGetValidatedOptionOrPromptValid(): void */ private function testGetValidatedOptionOrPromptInvalid(): void { - $result = $this->getValidatedOptionOrPrompt( + $result = $this->io->getValidatedOptionOrPrompt( 'name', - fn ($validate) => $this->promptText(label: 'Name:', validate: $validate), + fn ($validate) => $this->io->promptText(label: 'Name:', validate: $validate), fn ($value) => 'Always invalid' ); - $this->io->text('Result: '.($result ?? 'null')); + $this->io->writeln('Result: '.($result ?? 'null')); } /** @@ -212,12 +212,12 @@ private function testGetValidatedOptionOrPromptInvalid(): void */ private function testPromptSpinWrapper(): void { - $result = $this->promptSpin( + $result = $this->io->promptSpin( fn () => 'success', 'Testing...' ); - $this->io->text("Spin result: {$result}"); + $this->io->writeln("Spin result: {$result}"); } /** @@ -225,7 +225,7 @@ private function testPromptSpinWrapper(): void */ private function testPromptTextWrapper(): void { - $this->promptText('Test:', required: false); + $this->io->promptText('Test:', required: false); } /** @@ -233,7 +233,7 @@ private function testPromptTextWrapper(): void */ private function testPromptPasswordWrapper(): void { - $this->promptPassword('Test:', required: false); + $this->io->promptPassword('Test:', required: false); } /** @@ -241,7 +241,7 @@ private function testPromptPasswordWrapper(): void */ private function testPromptConfirmWrapper(): void { - $this->promptConfirm('Test:'); + $this->io->promptConfirm('Test:'); } /** @@ -249,7 +249,7 @@ private function testPromptConfirmWrapper(): void */ private function testPromptPauseWrapper(): void { - $this->promptPause('Test'); + $this->io->promptPause('Test'); } /** @@ -257,7 +257,7 @@ private function testPromptPauseWrapper(): void */ private function testPromptSelectWrapper(): void { - $this->promptSelect('Test:', ['a', 'b'], default: 'a'); + $this->io->promptSelect('Test:', ['a', 'b'], default: 'a'); } /** @@ -265,7 +265,7 @@ private function testPromptSelectWrapper(): void */ private function testPromptMultiselectWrapper(): void { - $this->promptMultiselect('Test:', ['a', 'b']); + $this->io->promptMultiselect('Test:', ['a', 'b']); } /** @@ -273,7 +273,7 @@ private function testPromptMultiselectWrapper(): void */ private function testPromptSuggestWrapper(): void { - $this->promptSuggest('Test:', ['a', 'b'], required: false); + $this->io->promptSuggest('Test:', ['a', 'b'], required: false); } /** @@ -281,6 +281,6 @@ private function testPromptSuggestWrapper(): void */ private function testPromptSearchWrapper(): void { - $this->promptSearch('Test:', fn ($q) => ['a', 'b']); + $this->io->promptSearch('Test:', fn ($q) => ['a', 'b']); } } diff --git a/tests/TestHelpers.php b/tests/TestHelpers.php index c8d57877..b1648051 100644 --- a/tests/TestHelpers.php +++ b/tests/TestHelpers.php @@ -8,12 +8,11 @@ use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\FilesystemService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use Bigpixelrocket\DeployerPHP\Services\IOService; use Bigpixelrocket\DeployerPHP\Services\ProcessService; -use Bigpixelrocket\DeployerPHP\Services\PrompterService; use Bigpixelrocket\DeployerPHP\Services\SSHService; use Bigpixelrocket\DeployerPHP\Services\VersionService; use Bigpixelrocket\DeployerPHP\Tests\Fixtures\MockFilesystem; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\MockPrompter; use Bigpixelrocket\DeployerPHP\Tests\Fixtures\MockSSHService; use Symfony\Component\Dotenv\Dotenv; use Symfony\Component\Filesystem\Filesystem; @@ -216,41 +215,20 @@ function mockSSHServiceWithBehavior(bool $canConnect = true): SSHService } } -if (!function_exists('mockPrompter')) { +if (!function_exists('mockIOService')) { /** - * Create a mock PrompterService for testing. + * Create an IOService for testing. * - * Returns predefined values instead of displaying interactive prompts. - * Values are consumed in order as prompts are called. - * - * @param array $text Text input values - * @param array $password Password input values - * @param array $confirm Confirmation values - * @param array $select Selection values - * @param array> $multiselect Multiselection values - * @param array $suggest Suggestion values - * @param array $search Search values - * @param array $pause Pause values - * - * @example - * // Mock text inputs - * $prompter = mockPrompter(text: ['web1', '192.168.1.1']); + * Returns a plain IOService instance. Must call initialize() before using I/O methods. + * Prompts will run in non-interactive mode during tests. * * @example - * // Mock confirmations - * $prompter = mockPrompter(confirm: [true, false]); + * $io = mockIOService(); + * $io->initialize($command, $input, $output); */ - function mockPrompter( - array $text = [], - array $password = [], - array $confirm = [], - array $select = [], - array $multiselect = [], - array $suggest = [], - array $search = [], - array $pause = [] - ): MockPrompter { - return new MockPrompter($text, $password, $confirm, $select, $multiselect, $suggest, $search, $pause); + function mockIOService(): IOService + { + return new IOService(); } } @@ -367,11 +345,11 @@ function mockSiteRepository( * $command = $container->build(ServerListCommand::class); */ function mockCommandContainer( - // Base services + // Base services (alphabetical order) ?EnvService $env = null, ?InventoryService $inventory = null, + ?IOService $io = null, ?ProcessService $proc = null, - ?PrompterService $prompter = null, // Servers & sites ?ServerRepository $servers = null, @@ -389,8 +367,8 @@ function mockCommandContainer( // Build or use provided services (matches BaseCommand constructor order) $env ??= mockEnvService($envFileExists, $envContent); $inventory ??= mockInventoryService($inventoryFileExists, $inventoryData); + $io ??= mockIOService(); $proc ??= mockProcessService(); - $prompter ??= mockPrompter(); $servers ??= mockServerRepository($inventoryFileExists, $inventoryData); $sites ??= mockSiteRepository($inventoryFileExists, $inventoryData); $ssh ??= mockSSHService(); @@ -398,8 +376,8 @@ function mockCommandContainer( // Bind services to container (matches BaseCommand constructor order) $container->bind(EnvService::class, $env); $container->bind(InventoryService::class, $inventory); + $container->bind(IOService::class, $io); $container->bind(ProcessService::class, $proc); - $container->bind(PrompterService::class, $prompter); $container->bind(ServerRepository::class, $servers); $container->bind(SiteRepository::class, $sites); $container->bind(SSHService::class, $ssh); diff --git a/tests/Unit/Contracts/BaseCommandTest.php b/tests/Unit/Contracts/BaseCommandTest.php index 3f659bb2..89ecbdeb 100644 --- a/tests/Unit/Contracts/BaseCommandTest.php +++ b/tests/Unit/Contracts/BaseCommandTest.php @@ -29,7 +29,7 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $result = parent::execute($input, $output); - $this->writeln('Test command executed successfully'); + $this->io->writeln('Test command executed successfully'); return $result; } } diff --git a/tests/Unit/Services/IOServiceTest.php b/tests/Unit/Services/IOServiceTest.php new file mode 100644 index 00000000..9a1e50ab --- /dev/null +++ b/tests/Unit/Services/IOServiceTest.php @@ -0,0 +1,239 @@ +command = $container->build(\Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand::class); + $this->tester = new CommandTester($this->command); + }); + + // + // Input Gathering - getOptionOrPrompt + // ------------------------------------------------------------------------------- + + it('handles option vs prompt scenarios correctly', function (array $options, string $method, mixed $expected) { + // ARRANGE + $this->command->setTestMethod($method); + + // ACT + $this->tester->execute($options); + $output = $this->tester->getDisplay(); + + // ASSERT + if (is_bool($expected)) { + expect($output)->toContain('Result: '.($expected ? 'true' : 'false')); + } else { + expect($output)->toContain("Result: {$expected}"); + } + })->with([ + 'string option provided' => [['--name' => 'production'], 'getOptionOrPrompt', 'production'], + 'empty string is valid value' => [['--name' => ''], 'getOptionOrPromptEmpty', ''], + 'no option executes closure' => [[], 'getOptionOrPromptEmpty', 'from-closure'], + 'boolean flag provided' => [['--yes' => true], 'getOptionOrPromptBoolean', true], + 'boolean flag not provided' => [[], 'getOptionOrPromptBoolean', false], + ]); + + it('supports different return types from prompt closure', function (mixed $value, string $display) { + // ARRANGE + $this->command->setTestMethod('getOptionOrPromptTypes', [$value]); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain("Result: {$display}"); + })->with([ + 'string' => ['text-value', 'text-value'], + 'integer' => [42, '42'], + 'boolean true' => [true, 'true'], + 'boolean false' => [false, 'false'], + 'array' => [['a', 'b'], '["a","b"]'], + ]); + + // + // Input Gathering - getValidatedOptionOrPrompt + // ------------------------------------------------------------------------------- + + it('validates CLI options and returns appropriate result', function (array $options, ?string $expected, bool $hasError) { + // ARRANGE + $this->command->setTestMethod('getValidatedOptionOrPromptValid'); + + // ACT + $this->tester->execute($options); + $output = $this->tester->getDisplay(); + + // ASSERT + if ($expected === null) { + expect($output)->toContain('Result: null'); + } else { + expect($output)->toContain("Result: {$expected}"); + } + + if ($hasError) { + expect($output)->toContain('✗'); + } + })->with([ + 'valid CLI option' => [['--name' => 'valid-name'], 'valid-name', false], + 'invalid CLI option (empty)' => [['--name' => ''], null, true], + ]); + + it('returns null when validator always fails', function () { + // ARRANGE + $this->command->setTestMethod('getValidatedOptionOrPromptInvalid'); + + // ACT + $this->tester->execute(['--name' => 'anything']); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('✗') + ->and($output)->toContain('Always invalid') + ->and($output)->toContain('Result: null'); + }); + + // + // Output Methods - Status Messages + // ------------------------------------------------------------------------------- + + it('displays status messages with correct symbols and colors', function (string $method, string $message, string $symbol) { + // ARRANGE + $this->command->setTestMethod($method, [$message]); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain($symbol) + ->and($output)->toContain($message); + })->with([ + 'success' => ['success', 'Server added successfully', '✓'], + 'error' => ['error', 'Connection failed', '✗'], + 'warning' => ['warning', 'Skipping connection check', '⚠'], + 'info' => ['info', 'Configuration loaded', 'ℹ'], + ]); + + // + // Output Methods - Formatting + // ------------------------------------------------------------------------------- + + it('writes single and multiple lines correctly', function (string|array $lines, array $expectedContains) { + // ARRANGE + $this->command->setTestMethod('writeln', [$lines]); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + foreach ($expectedContains as $text) { + expect($output)->toContain($text); + } + })->with([ + 'single line' => ['Output line', ['Output line']], + 'multiple lines' => [['First line', 'Second line'], ['First line', 'Second line']], + ]); + + it('displays visual separators correctly', function (string $method, string $expectedContent) { + // ARRANGE + $this->command->setTestMethod($method, $method === 'h1' ? ['Server Configuration'] : []); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain($expectedContent); + })->with([ + 'h1 heading' => ['h1', '▸'], + 'h1 text' => ['h1', 'Server Configuration'], + 'hr separator' => ['hr', '╭───────'], + ]); + + // + // Output Methods - Command Hints + // ------------------------------------------------------------------------------- + + it('displays command hint with formatted options', function () { + // ARRANGE + $this->command->setTestMethod('showCommandHint', [ + 'server:add', + ['name' => 'prod-server', 'host' => '192.168.1.100', 'yes' => true], + ]); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Run non-interactively:') + ->and($output)->toContain('server:add') + ->and($output)->toContain('--name') + ->and($output)->toContain('--host') + ->and($output)->toContain('--yes'); + }); + + it('formats command options correctly and skips null/empty values', function (array $options, array $shouldContain, array $shouldNotContain) { + // ARRANGE + $this->command->setTestMethod('showCommandHint', ['server:add', $options]); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + foreach ($shouldContain as $text) { + expect($output)->toContain($text); + } + + foreach ($shouldNotContain as $text) { + expect($output)->not->toContain($text); + } + })->with([ + 'string options' => [ + ['name' => 'server1', 'host' => '192.168.1.1'], + ['--name', '--host', 'server1', '192.168.1.1'], + [], + ], + 'skip null and empty' => [ + ['name' => 'server1', 'host' => null, 'port' => ''], + ['--name'], + ['--host', '--port'], + ], + 'boolean true shown' => [ + ['yes' => true], + ['--yes'], + [], + ], + 'boolean false skipped' => [ + ['yes' => false], + [], + ['--yes'], + ], + ]); + + // + // Prompt Methods - Spin + // ------------------------------------------------------------------------------- + + it('executes callback and returns result from promptSpin', 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/Services/PrompterServiceTest.php b/tests/Unit/Services/PrompterServiceTest.php deleted file mode 100644 index 082f7520..00000000 --- a/tests/Unit/Services/PrompterServiceTest.php +++ /dev/null @@ -1,103 +0,0 @@ -text('Test prompt', required: true); - } 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); - }); - - it('suppresses spacing for all prompt types', function (string $method) { - // ARRANGE - $expectedAnsi = "\033[1A\033[2K"; - $service = new PrompterService(); - - // ACT - ob_start(); - - try { - // Call each prompt method to verify ANSI output - match ($method) { - 'text' => $service->text('Label'), - 'password' => $service->password('Label'), - 'confirm' => $service->confirm('Label'), - 'pause' => $service->pause(), - 'select' => $service->select('Label', ['a' => 'Option A']), - 'multiselect' => $service->multiselect('Label', ['a' => 'Option A']), - 'suggest' => $service->suggest('Label', ['option']), - 'search' => $service->search('Label', fn () => ['a' => 'Option A']), - default => throw new \InvalidArgumentException("Unknown method: {$method}") - }; - } catch (Throwable) { - // Expected to fail in non-interactive mode - } - - $output = ob_get_clean(); - - // ASSERT - expect($output)->toContain($expectedAnsi); - })->with([ - 'text', - 'password', - 'confirm', - 'pause', - 'select', - 'multiselect', - 'suggest', - 'search', - ]); - - it('does not suppress spacing for spin method', function () { - // ARRANGE - $expectedAnsi = "\033[1A\033[2K"; - $service = new PrompterService(); - $callbackExecuted = false; - - // ACT - ob_start(); - - $result = $service->spin( - callback: function () use (&$callbackExecuted) { - $callbackExecuted = true; - return 'result'; - }, - message: 'Loading...' - ); - - $output = ob_get_clean(); - - // ASSERT - // spin() doesn't call suppressPromptSpacing() - verify no ANSI output - expect($output)->not->toContain($expectedAnsi) - ->and($result)->toBe('result') - ->and($callbackExecuted)->toBeTrue(); - }); -}); diff --git a/tests/Unit/Traits/ConsoleInputTraitTest.php b/tests/Unit/Traits/ConsoleInputTraitTest.php deleted file mode 100644 index 6ba55b47..00000000 --- a/tests/Unit/Traits/ConsoleInputTraitTest.php +++ /dev/null @@ -1,204 +0,0 @@ -command = $container->build(\Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand::class); - $this->tester = new CommandTester($this->command); - }); - - // - // getOptionOrPrompt - // ------------------------------------------------------------------------------- - - // - // String Options - - it('returns option value when string option provided', function () { - // ARRANGE - $this->command->setTestMethod('getOptionOrPrompt'); - - // ACT - $this->tester->execute(['--name' => 'production']); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('Result: production'); - }); - - it('returns empty string when option is explicitly set to empty', function () { - // ARRANGE - $this->command->setTestMethod('getOptionOrPromptEmpty'); - - // ACT - $this->tester->execute(['--name' => '']); - $output = $this->tester->getDisplay(); - - // ASSERT - Empty string is a valid value, so closure should NOT execute - expect($output)->not->toContain('Closure executed') - ->and($output)->toContain('Result:'); - }); - - 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 - // ------------------------------------------------------------------------------- - - // Note: Spacing suppression is now handled by PrompterService internally. - // When using MockPrompter in tests, no ANSI sequences are output (as expected). - // The real PrompterService handles spacing suppression for actual prompts. - - 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'); - }); - - // - // getValidatedOptionOrPrompt - // ------------------------------------------------------------------------------- - - describe('getValidatedOptionOrPrompt', function () { - beforeEach(function () { - $container = mockCommandContainer(); - $this->command = $container->build(\Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand::class); - $this->tester = new CommandTester($this->command); - }); - - it('returns validated value when CLI option is valid', function () { - // ARRANGE - $this->command->setTestMethod('getValidatedOptionOrPromptValid'); - - // ACT - $this->tester->execute(['--name' => 'valid-name']); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('Result: valid-name'); - }); - - it('returns null and shows error when CLI option is invalid', function () { - // ARRANGE - $this->command->setTestMethod('getValidatedOptionOrPromptInvalid'); - - // ACT - $this->tester->execute(['--name' => 'anything']); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('✗') - ->and($output)->toContain('Always invalid') - ->and($output)->toContain('Result: null'); - }); - - it('returns null when CLI option fails validation', function () { - // ARRANGE - $this->command->setTestMethod('getValidatedOptionOrPromptValid'); - - // ACT - $this->tester->execute(['--name' => '']); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('✗') - ->and($output)->toContain('Cannot be empty') - ->and($output)->toContain('Result: null'); - }); - - it('validator is passed to prompt callback in non-interactive mode', function () { - // ARRANGE - Create command with mock prompter that has a value queued - $mockPrompter = mockPrompter(text: ['prompted-value']); - $container = mockCommandContainer(prompter: $mockPrompter); - $command = $container->build(\Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand::class); - $tester = new CommandTester($command); - $command->setTestMethod('getValidatedOptionOrPromptValid'); - - // ACT - No --name option, will use prompter - $tester->execute([]); - $output = $tester->getDisplay(); - - // ASSERT - MockPrompter returned value, validator was passed and validated - expect($output)->toContain('Result: prompted-value'); - }); - }); -}); diff --git a/tests/Unit/Traits/ConsoleOutputTraitTest.php b/tests/Unit/Traits/ConsoleOutputTraitTest.php deleted file mode 100644 index 181b3948..00000000 --- a/tests/Unit/Traits/ConsoleOutputTraitTest.php +++ /dev/null @@ -1,189 +0,0 @@ -command = $container->build(\Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand::class); - $this->tester = new CommandTester($this->command); - }); - - // - // Raw output - // ------------------------------------------------------------------------------- - - 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'); - }); - - // - // Message helpers - // ------------------------------------------------------------------------------- - - it('displays info message with cyan info symbol', function () { - // ARRANGE - $this->command->setTestMethod('info', ['Information message']); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('ℹ') - ->and($output)->toContain('Information message'); - }); - - it('displays success message with green checkmark', function () { - // ARRANGE - $this->command->setTestMethod('success', ['Server added successfully']); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('✓') - ->and($output)->toContain('Server added successfully'); - }); - - it('displays warning message with yellow warning symbol', function () { - // ARRANGE - $this->command->setTestMethod('warning', ['Skipping connection check']); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('⚠') - ->and($output)->toContain('Skipping connection check'); - }); - - it('displays error message with red X symbol', function () { - // ARRANGE - $this->command->setTestMethod('error', ['Connection failed']); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('✗') - ->and($output)->toContain('Connection failed'); - }); - - // - // Heading and separator - // ------------------------------------------------------------------------------- - - it('displays heading with icon', function () { - // ARRANGE - $this->command->setTestMethod('h1', ['Server Configuration']); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('▸') - ->and($output)->toContain('Server Configuration'); - }); - - it('displays separator line with box-drawing characters', function () { - // ARRANGE - $this->command->setTestMethod('hr'); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('╭───────'); - }); - - // - // 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], - ]); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('Run non-interactively:') - ->and($output)->toContain('server:add') - ->and($output)->toContain('--name') - ->and($output)->toContain('--host') - ->and($output)->toContain('--yes'); - }); - - it('formats command options correctly in hint', function () { - // ARRANGE - $this->command->setTestMethod('showCommandHint', [ - 'server:add', - ['name' => 'prod-server', 'host' => '192.168.1.100'], - ]); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('--name') - ->and($output)->toContain('--host') - ->and($output)->toContain('prod-server') - ->and($output)->toContain('192.168.1.100'); - }); - - it('skips null and empty values in command hint', function () { - // ARRANGE - $this->command->setTestMethod('showCommandHint', [ - 'server:add', - ['name' => 'prod-server', 'host' => null, 'port' => ''], - ]); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('--name') - ->and($output)->not->toContain('--host') - ->and($output)->not->toContain('--port'); - }); -});