From a341cf099ae28a1f37bde8d86c532640f7ed66ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 12 Oct 2025 23:04:08 +0300 Subject: [PATCH 1/4] refactor(io): introduce IOService for unified console I/O Consolidate input gathering and output formatting from separate traits and PrompterService into a single DI-injected IOService. Includes wrappers for Laravel Prompts with spacing suppression, validation helpers, and custom styling methods. --- app/Services/IOService.php | 577 ++++++++++++++++++++++++++ tests/Unit/Services/IOServiceTest.php | 239 +++++++++++ 2 files changed, 816 insertions(+) create mode 100644 app/Services/IOService.php create mode 100644 tests/Unit/Services/IOServiceTest.php diff --git a/app/Services/IOService.php b/app/Services/IOService.php new file mode 100644 index 00000000..03cfefcc --- /dev/null +++ b/app/Services/IOService.php @@ -0,0 +1,577 @@ +command = $command; + $this->input = $input; + $this->io = new SymfonyStyle($input, $output); + } + + // + // Input Gathering + // ------------------------------------------------------------------------------- + + /** + * 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 a custom closure. + * + * @template T + * + * @param string $optionName The option name to check + * @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->io->getOptionOrPrompt( + * 'name', + * fn() => $this->io->promptText('Server name:', placeholder: 'web1') + * ); + * + * // Boolean flag (VALUE_NONE option) + * $skip = $this->io->getOptionOrPrompt( + * 'skip', + * fn() => $this->io->promptConfirm('Skip verification?', default: false) + * ); + * + * // Select input + * $env = $this->io->getOptionOrPrompt( + * 'environment', + * fn() => $this->io->promptSelect('Environment:', ['dev', 'staging', 'prod']) + * ); + */ + public function getOptionOrPrompt( + string $optionName, + Closure $promptCallback + ): mixed { + $value = $this->input->getOption($optionName); + + // For boolean flags (VALUE_NONE options), check if actually provided + if (is_bool($value)) { + // Build list of option flags to check + $optionFlags = ['--' . $optionName]; + + // Try to find short flag from option definition + try { + $inputDef = $this->command->getDefinition(); + if ($inputDef->hasOption($optionName)) { + $option = $inputDef->getOption($optionName); + if ($option->getShortcut() !== null) { + $optionFlags[] = '-' . $option->getShortcut(); + } + } + } catch (\Throwable) { + // Ignore errors getting shortcut + } + + // Check if flag was actually provided (works for both CLI and tests with ArrayInput) + $wasProvided = $this->input->hasParameterOption($optionFlags, true); + + if ($wasProvided) { + // Flag was provided - return its value (true for CLI flags, could be false in tests) + return $value; + } + + // Flag was not provided - prompt in interactive mode, return false otherwise + if ($this->input->isInteractive()) { + return $promptCallback(); + } + + return false; + } + + // Handle string options (including empty strings) + // null means option was not provided, empty string means it was provided but empty + if ($value !== null) { + return $value; + } + + // Prompt user interactively + return $promptCallback(); + } + + /** + * Get option value or prompt user, with automatic validation. + * + * Combines getOptionOrPrompt with validation. The validator is automatically + * applied to both interactive prompts and CLI options. + * + * @param string $optionName The option name to check + * @param Closure(Closure): mixed $promptCallback Closure that receives validator and returns prompt result + * @param Closure(mixed): ?string $validator Validation closure that returns error message or null + * + * @return mixed The validated value, or null if validation failed + * + * @example + * $name = $this->io->getValidatedOptionOrPrompt( + * 'name', + * fn($validate) => $this->io->promptText( + * label: 'Server name:', + * validate: $validate + * ), + * fn($value) => $this->validateNameInput($value) + * ); + * if ($name === null) { + * return Command::FAILURE; + * } + */ + public function getValidatedOptionOrPrompt( + string $optionName, + Closure $promptCallback, + Closure $validator + ): mixed { + // Pass validator to prompt callback + $value = $this->getOptionOrPrompt( + $optionName, + fn () => $promptCallback($validator) + ); + + // Validate if value came from CLI option (prompts already validated) + if ($this->input->getOption($optionName) !== null) { + $error = $validator($value); + if ($error !== null) { + $this->error($error); + + return null; + } + } + + return $value; + } + + // + // Laravel Prompts Wrappers + // ------------------------------------------------------------------------------- + + /** + * 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 + */ + public 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 + */ + public function promptPassword( + 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. + * + * @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 + */ + public function promptConfirm( + 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. + * + * @param string $message The message to display + * + * @return bool Always returns a boolean + */ + public 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 + */ + public 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 + */ + public 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 + */ + public 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, + 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 + */ + public 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 + */ + public function promptSpin( + Closure $callback, + string $message = 'Loading...' + ): mixed { + 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/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'); + }); +}); From d16524d6a3ae75093029f9573d8408a8727cb798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 12 Oct 2025 23:04:10 +0300 Subject: [PATCH 2/4] refactor(base): integrate IOService into BaseCommand Update BaseCommand constructor and initialize() to inject and setup IOService. Remove obsolete traits and PrompterService dependency. Adjust test fixtures and helpers accordingly. --- app/Contracts/BaseCommand.php | 40 +++++------ tests/Fixtures/TestConsoleCommand.php | 86 ++++++++++++------------ tests/TestHelpers.php | 48 ++++--------- tests/Unit/Contracts/BaseCommandTest.php | 2 +- 4 files changed, 72 insertions(+), 104 deletions(-) diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php index e6cf3550..2539a9c2 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( @@ -47,10 +37,10 @@ public function __construct( protected readonly Container $container, // Base services + protected readonly IOService $io, protected readonly EnvService $env, protected readonly InventoryService $inventory, 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/tests/Fixtures/TestConsoleCommand.php b/tests/Fixtures/TestConsoleCommand.php index c3066af0..5b8c0dfc 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 { @@ -35,25 +35,25 @@ class TestConsoleCommand extends BaseCommand * Create a TestConsoleCommand instance with the required service and repository dependencies. * * @param Container $container Dependency injection container. + * @param IOService $io I/O service for console operations. * @param EnvService $env Environment service. * @param InventoryService $inventory Inventory management service. * @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. */ public function __construct( Container $container, + IOService $io, EnvService $env, InventoryService $inventory, ProcessService $proc, - PrompterService $prompter, ServerRepository $servers, SiteRepository $sites, SSHService $ssh, ) { - parent::__construct($container, $env, $inventory, $proc, $prompter, $servers, $sites, $ssh); + parent::__construct($container, $io, $env, $inventory, $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..41db423c 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(); } } @@ -368,10 +346,10 @@ function mockSiteRepository( */ function mockCommandContainer( // Base services + ?IOService $io = null, ?EnvService $env = null, ?InventoryService $inventory = null, ?ProcessService $proc = null, - ?PrompterService $prompter = null, // Servers & sites ?ServerRepository $servers = null, @@ -387,19 +365,19 @@ function mockCommandContainer( $container = new Container(); // Build or use provided services (matches BaseCommand constructor order) + $io ??= mockIOService(); $env ??= mockEnvService($envFileExists, $envContent); $inventory ??= mockInventoryService($inventoryFileExists, $inventoryData); $proc ??= mockProcessService(); - $prompter ??= mockPrompter(); $servers ??= mockServerRepository($inventoryFileExists, $inventoryData); $sites ??= mockSiteRepository($inventoryFileExists, $inventoryData); $ssh ??= mockSSHService(); // Bind services to container (matches BaseCommand constructor order) + $container->bind(IOService::class, $io); $container->bind(EnvService::class, $env); $container->bind(InventoryService::class, $inventory); $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; } } From 2cd5807a627ea3516ac7e403ab9a2dea4a0855a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 12 Oct 2025 23:04:16 +0300 Subject: [PATCH 3/4] refactor(console): migrate commands to IOService and cleanup Update all console commands and ServerHelpersTrait to use IOService methods. Remove obsolete PrompterService, ConsoleInputTrait, ConsoleOutputTrait, and related tests. --- app/Console/HelloCommand.php | 2 +- app/Console/Server/ServerAddCommand.php | 58 +-- app/Console/Server/ServerDeleteCommand.php | 18 +- app/Console/Server/ServerListCommand.php | 8 +- app/Services/PrompterService.php | 246 ------------ app/Traits/ConsoleInputTrait.php | 398 ------------------- app/Traits/ConsoleOutputTrait.php | 139 ------- app/Traits/ServerHelpersTrait.php | 15 +- tests/Fixtures/MockPrompter.php | 202 ---------- tests/Unit/Services/PrompterServiceTest.php | 103 ----- tests/Unit/Traits/ConsoleInputTraitTest.php | 204 ---------- tests/Unit/Traits/ConsoleOutputTraitTest.php | 189 --------- 12 files changed, 51 insertions(+), 1531 deletions(-) delete mode 100644 app/Services/PrompterService.php delete mode 100644 app/Traits/ConsoleInputTrait.php delete mode 100644 app/Traits/ConsoleOutputTrait.php delete mode 100644 tests/Fixtures/MockPrompter.php delete mode 100644 tests/Unit/Services/PrompterServiceTest.php delete mode 100644 tests/Unit/Traits/ConsoleInputTraitTest.php delete mode 100644 tests/Unit/Traits/ConsoleOutputTraitTest.php 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/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/ConsoleInputTrait.php b/app/Traits/ConsoleInputTrait.php deleted file mode 100644 index 29236d8d..00000000 --- a/app/Traits/ConsoleInputTrait.php +++ /dev/null @@ -1,398 +0,0 @@ -getOptionOrPrompt( - * 'name', - * fn() => text('Server name:', placeholder: 'web1') - * ); - * - * // 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); - - // For boolean flags (VALUE_NONE options), check if actually provided - if (is_bool($value)) { - // Build list of option flags to check - $optionFlags = ['--' . $optionName]; - - // Try to find short flag from option definition - try { - $inputDef = $this->getDefinition(); - if ($inputDef->hasOption($optionName)) { - $option = $inputDef->getOption($optionName); - if ($option->getShortcut() !== null) { - $optionFlags[] = '-' . $option->getShortcut(); - } - } - } catch (\Throwable) { - // Ignore errors getting shortcut - } - - // Check if flag was actually provided (works for both CLI and tests with ArrayInput) - $wasProvided = $this->input->hasParameterOption($optionFlags, true); - - if ($wasProvided) { - // Flag was provided - return its value (true for CLI flags, could be false in tests) - return $value; - } - - // Flag was not provided - prompt in interactive mode, return false otherwise - if ($this->input->isInteractive()) { - return $promptCallback(); - } - - return false; - } - - // Handle string options (including empty strings) - // null means option was not provided, empty string means it was provided but empty - if ($value !== null) { - return $value; - } - - // Prompt user interactively - return $promptCallback(); - } - - /** - * Get option value or prompt user, with automatic validation. - * - * Combines getOptionOrPrompt with validation. The validator is automatically - * applied to both interactive prompts and CLI options. - * - * @param string $optionName The option name to check - * @param Closure(Closure): mixed $promptCallback Closure that receives validator and returns prompt result - * @param Closure(mixed): ?string $validator Validation closure that returns error message or null - * - * @return mixed The validated value, or null if validation failed - * - * @example - * $name = $this->getValidatedOptionOrPrompt( - * 'name', - * fn($validate) => $this->promptText( - * label: 'Server name:', - * validate: $validate - * ), - * fn($value) => $this->validateNameInput($value) - * ); - * if ($name === null) { - * return Command::FAILURE; - * } - */ - protected function getValidatedOptionOrPrompt( - string $optionName, - Closure $promptCallback, - Closure $validator - ): mixed { - // Pass validator to prompt callback - $value = $this->getOptionOrPrompt( - $optionName, - fn () => $promptCallback($validator) - ); - - // Validate if value came from CLI option (prompts already validated) - if ($this->input->getOption($optionName) !== null) { - $error = $validator($value); - if ($error !== null) { - $this->error($error); - - return null; - } - } - - return $value; - } - - // - // Laravel Prompts Wrappers - // ------------------------------------------------------------------------------- - - /** - * 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 { - return $this->prompter->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 $required = true, - mixed $validate = null, - string $hint = '' - ): string { - return $this->prompter->password( - label: $label, - placeholder: $placeholder, - required: $required, - validate: $validate, - hint: $hint - ); - } - - /** - * 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 { - return $this->prompter->confirm( - label: $label, - default: $default, - yes: $yes, - no: $no, - hint: $hint - ); - } - - /** - * 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 - { - return $this->prompter->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 { - return $this->prompter->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 { - return $this->prompter->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 { - return $this->prompter->suggest( - label: $label, - options: $options, - placeholder: $placeholder, - default: $default, - 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 { - return $this->prompter->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 $this->prompter->spin( - callback: $callback, - message: $message - ); - } -} 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/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'); - }); -}); From 6876527847759fb33eee92d1dc6d6889be5c0404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 12 Oct 2025 23:14:51 +0300 Subject: [PATCH 4/4] refactor(base-command): reorder constructor parameters alphabetically Reorder service parameters in BaseCommand constructor to follow alphabetical order (env, inventory, io, proc). Add comment in IOService initialize() explaining command usage for input inspection. Update TestConsoleCommand constructor and docblock to match new order. Align mockCommandContainer() parameters, assignments, and bindings in TestHelpers.php with alphabetical order, including updated comment. --- app/Contracts/BaseCommand.php | 2 +- app/Services/IOService.php | 2 +- tests/Fixtures/TestConsoleCommand.php | 6 +++--- tests/TestHelpers.php | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php index 2539a9c2..5a20519b 100644 --- a/app/Contracts/BaseCommand.php +++ b/app/Contracts/BaseCommand.php @@ -37,9 +37,9 @@ public function __construct( protected readonly Container $container, // Base services - protected readonly IOService $io, protected readonly EnvService $env, protected readonly InventoryService $inventory, + protected readonly IOService $io, protected readonly ProcessService $proc, // Servers & sites diff --git a/app/Services/IOService.php b/app/Services/IOService.php index 03cfefcc..105336f1 100644 --- a/app/Services/IOService.php +++ b/app/Services/IOService.php @@ -40,7 +40,7 @@ class IOService */ public function initialize(Command $command, InputInterface $input, OutputInterface $output): void { - $this->command = $command; + $this->command = $command; // Used to inspect input definitions (like --yes and -y, etc.) $this->input = $input; $this->io = new SymfonyStyle($input, $output); } diff --git a/tests/Fixtures/TestConsoleCommand.php b/tests/Fixtures/TestConsoleCommand.php index 5b8c0dfc..4563ad4d 100644 --- a/tests/Fixtures/TestConsoleCommand.php +++ b/tests/Fixtures/TestConsoleCommand.php @@ -35,9 +35,9 @@ class TestConsoleCommand extends BaseCommand * Create a TestConsoleCommand instance with the required service and repository dependencies. * * @param Container $container Dependency injection container. - * @param IOService $io I/O service for console operations. * @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 ServerRepository $servers Repository for server records. * @param SiteRepository $sites Repository for site records. @@ -45,15 +45,15 @@ class TestConsoleCommand extends BaseCommand */ public function __construct( Container $container, - IOService $io, EnvService $env, InventoryService $inventory, + IOService $io, ProcessService $proc, ServerRepository $servers, SiteRepository $sites, SSHService $ssh, ) { - parent::__construct($container, $io, $env, $inventory, $proc, $servers, $sites, $ssh); + parent::__construct($container, $env, $inventory, $io, $proc, $servers, $sites, $ssh); } /** diff --git a/tests/TestHelpers.php b/tests/TestHelpers.php index 41db423c..b1648051 100644 --- a/tests/TestHelpers.php +++ b/tests/TestHelpers.php @@ -345,10 +345,10 @@ function mockSiteRepository( * $command = $container->build(ServerListCommand::class); */ function mockCommandContainer( - // Base services - ?IOService $io = null, + // Base services (alphabetical order) ?EnvService $env = null, ?InventoryService $inventory = null, + ?IOService $io = null, ?ProcessService $proc = null, // Servers & sites @@ -365,18 +365,18 @@ function mockCommandContainer( $container = new Container(); // Build or use provided services (matches BaseCommand constructor order) - $io ??= mockIOService(); $env ??= mockEnvService($envFileExists, $envContent); $inventory ??= mockInventoryService($inventoryFileExists, $inventoryData); + $io ??= mockIOService(); $proc ??= mockProcessService(); $servers ??= mockServerRepository($inventoryFileExists, $inventoryData); $sites ??= mockSiteRepository($inventoryFileExists, $inventoryData); $ssh ??= mockSSHService(); // Bind services to container (matches BaseCommand constructor order) - $container->bind(IOService::class, $io); $container->bind(EnvService::class, $env); $container->bind(InventoryService::class, $inventory); + $container->bind(IOService::class, $io); $container->bind(ProcessService::class, $proc); $container->bind(ServerRepository::class, $servers); $container->bind(SiteRepository::class, $sites);