From d80e9c470a1ac3a8a10c423023a66b5c945c803f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 2 Oct 2025 23:03:49 +0300 Subject: [PATCH 1/8] refactor(console): extract output methods to ConsoleOutputTrait Extract console output formatting methods from BaseCommand into a reusable ConsoleOutputTrait to promote code reuse and separation of concerns. Changes: - Create ConsoleOutputTrait with status messages (error, success, warning) - Add output formatting methods (h1, hr, text, writeln) - Add user input helper (getOptionOrPrompt) - Add command hint display (showCommandHint) - Refactor BaseCommand to use the new trait - Add comprehensive test coverage (ConsoleOutputTraitTest) - Update BaseCommandTest to reflect extracted methods - Update documentation in .cursor/rules/03-commands.mdc --- .cursor/rules/03-commands.mdc | 128 ++++++- app/Console/HelloCommand.php | 2 +- app/Contracts/BaseCommand.php | 49 +-- app/Traits/ConsoleOutputTrait.php | 199 +++++++++++ tests/Unit/Contracts/BaseCommandTest.php | 54 +-- tests/Unit/Traits/ConsoleOutputTraitTest.php | 338 +++++++++++++++++++ 6 files changed, 662 insertions(+), 108 deletions(-) create mode 100644 app/Traits/ConsoleOutputTrait.php create mode 100644 tests/Unit/Traits/ConsoleOutputTraitTest.php diff --git a/.cursor/rules/03-commands.mdc b/.cursor/rules/03-commands.mdc index b28e138a..807d9ecd 100644 --- a/.cursor/rules/03-commands.mdc +++ b/.cursor/rules/03-commands.mdc @@ -21,8 +21,14 @@ alwaysApply: true ```php // ✅ CORRECT - BaseCommand custom methods $this->writeln(['Multiple', 'lines']); // Multi-line output -$this->text('Single line message'); // Simple text output -$this->hr(); // Beautiful section separator +$this->text('Single line message'); // Simple text output +$this->hr(); // Beautiful section separator +$this->h1('Section Heading'); // Heading with icon + +// Status messages +$this->success('Server added successfully'); +$this->error('Failed to connect', 'Check your SSH key permissions'); +$this->warning('Skipping connection check'); ``` **❌ FORBIDDEN - Direct Symfony IO Usage:** @@ -53,7 +59,38 @@ protected function success(string $message): void { **Integration Points:** - Base implementation: [BaseCommand.php](mdc:app/Contracts/BaseCommand.php) -- Current methods: `writeln()`, `text()`, `hr()` +- Output methods: `writeln()`, `text()`, `hr()`, `h1()` +- Status methods: `success()`, `error()`, `warning()` +- Input methods: `getOptionOrPrompt()` +- Helper methods: `showCommandHint()` + +### 📣 Status Message Helpers + +**Use status helpers for consistent success/error/warning messages:** + +```php +// ✅ Success messages (green checkmark) +$this->success('Server added successfully'); +$this->success('Connection successful'); + +// ✅ Error messages (red X) with optional tip +$this->error('Failed to connect to server'); +$this->error( + 'Failed to add server: ' . $e->getMessage(), + 'Use server:list to view existing servers.' +); + +// ✅ Warning messages (yellow warning symbol) +$this->warning('Skipping SSH connection check'); +$this->warning('Server add cancelled'); +``` + +**Benefits:** + +- Consistent formatting across all commands +- Automatic spacing (blank line after message) +- Clear visual indicators (✓, ✗, ⚠) +- Optional tips for error messages ### 🎯 User Input with Laravel Prompts @@ -69,16 +106,14 @@ use function Laravel\Prompts\suggest; use function Laravel\Prompts\search; use function Laravel\Prompts\spin; -// ✅ CORRECT - Modern prompts with interaction check -if ($this->io->isInteractive()) { - $name = text('What is your name?', required: true); - $password = password('Enter password:', required: true); - $confirmed = confirm('Deploy to production?', default: false); - $environment = select('Select environment:', ['dev', 'staging', 'prod']); - $features = multiselect('Enable features:', ['cache', 'queue', 'logs']); -} +// ✅ CORRECT - Modern prompts +$name = text('What is your name?', required: true); +$password = password('Enter password:', required: true); +$confirmed = confirm('Deploy to production?', default: false); +$environment = select('Select environment:', ['dev', 'staging', 'prod']); +$features = multiselect('Enable features:', ['cache', 'queue', 'logs']); -// Spinners work in all modes +// Spinners for long operations $result = spin(fn() => $this->service->heavyOperation(), 'Processing...'); ``` @@ -128,3 +163,72 @@ class MyCommand extends BaseCommand { - Clean separation between business logic and presentation **Rule:** Commands orchestrate Services and format output. Services never touch console I/O. + +### 🔀 Interactive + Options Pattern + +**Support both interactive prompts AND command-line options for maximum flexibility:** + +```php +protected function configure(): void { + parent::configure(); + + $this->addOption('name', null, InputOption::VALUE_REQUIRED, 'Server name'); + $this->addOption('host', null, InputOption::VALUE_REQUIRED, 'Host/IP address'); + $this->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation'); +} + +protected function execute(InputInterface $input, OutputInterface $output): int { + // Track which options were provided vs prompted + $provided = []; + + // Use BaseCommand helper method + $name = $this->getOptionOrPrompt( + $input, + 'name', + 'Server name:', + placeholder: 'production-web-01', + wasProvided: $provided['name'] + ); + + $host = $this->getOptionOrPrompt( + $input, + 'host', + 'Host/IP address:', + placeholder: '192.168.1.100', + wasProvided: $provided['host'] + ); + + // ... process command ... + + // Show command hint with highlighted missing options + $this->showCommandHint('my:command', [ + 'name' => $name, + 'host' => $host, + 'yes' => true, + ], $provided); + + return Command::SUCCESS; +} +``` + +**`getOptionOrPrompt()` Helper:** + +The `getOptionOrPrompt()` method is available in BaseCommand for all commands: + +- Checks if option was provided via CLI +- If yes, returns the value and sets `wasProvided` to `true` +- If not, prompts user interactively and sets `wasProvided` to `false` +- Supports all Laravel Prompts parameters (label, default, required, placeholder) + +**Command Hint Output:** + +- Gray = option was provided +- Bright yellow = option was prompted (user should include next time) +- Shows complete non-interactive command for copy/paste + +**Benefits:** + +- Script-friendly with full option support +- User-friendly with interactive fallbacks +- Educational - teaches users the non-interactive syntax +- DRY - single command serves both use cases diff --git a/app/Console/HelloCommand.php b/app/Console/HelloCommand.php index 16ec8b25..b99fc230 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->io->success('Hello ' . $user . '!'); + $this->success('Hello ' . $user . '!'); return Command::SUCCESS; } diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php index d2108927..a7dab7ad 100644 --- a/app/Contracts/BaseCommand.php +++ b/app/Contracts/BaseCommand.php @@ -7,14 +7,20 @@ use Bigpixelrocket\DeployerPHP\Container; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; +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; -use Symfony\Component\Console\Input\InputOption; +/** + * Base command with shared functionality for all commands. + */ abstract class BaseCommand extends Command { + use ConsoleOutputTrait; + protected SymfonyStyle $io; public function __construct( @@ -99,45 +105,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - - // - // Output helpers - // ------------------------------------------------------------------------------- - - /** - * Write-out multiple lines. - * - * @param array $lines - */ - protected function writeln(string|array $lines): void - { - $writeLines = is_array($lines) ? $lines : [$lines]; - foreach ($writeLines as $line) { - $this->io->writeln(' ' . $line); - } - } - - /** - * Write-out styled text lines. - * - * @param array $lines - */ - protected function text(string|array $lines): void - { - $writeLines = is_array($lines) ? $lines : [$lines]; - foreach ($writeLines as $line) { - $this->io->text(' ' . $line); - } - } - - /** - * Write-out a separator line. - */ - protected function hr(): void - { - $this->writeln([ - '╭──────────────────────────────────────────', - '', - ]); - } } diff --git a/app/Traits/ConsoleOutputTrait.php b/app/Traits/ConsoleOutputTrait.php new file mode 100644 index 00000000..083905b8 --- /dev/null +++ b/app/Traits/ConsoleOutputTrait.php @@ -0,0 +1,199 @@ +✗ {$message}", + '', + ]; + + if ($tip !== null) { + $output[] = "Tip: {$tip}"; + $output[] = ''; + } + + $this->writeln($output); + } + + /** + * 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}", + '', + ]); + } + + // + // Output Formatting + // ------------------------------------------------------------------------------- + + /** + * Write-out a heading. + */ + protected function h1(string $text): void + { + $this->writeln(''.$text.''); + } + + /** + * Write-out a separator line. + */ + protected function hr(): void + { + $this->writeln([ + '╭──────────────────────────────────────────', + '', + ]); + } + + /** + * Write-out styled text lines. + * + * @param array $lines + */ + protected function text(string|array $lines): void + { + $writeLines = is_array($lines) ? $lines : [$lines]; + foreach ($writeLines as $line) { + $this->io->text(' '.$line); + } + } + + /** + * Write-out multiple lines. + * + * @param array $lines + */ + protected function writeln(string|array $lines): void + { + $writeLines = is_array($lines) ? $lines : [$lines]; + foreach ($writeLines as $line) { + $this->io->writeln(' '.$line); + } + } + + // + // User Input Helpers + // ------------------------------------------------------------------------------- + + /** + * Get option value or prompt user interactively. + * + * Checks if an option was provided via CLI. If yes, returns the value and sets + * wasProvided to true. If not, prompts user interactively and sets wasProvided to false. + * + * @param bool $wasProvided Set to true if option was provided, false if prompted + * @return string The option value (from CLI or prompt) + */ + protected function getOptionOrPrompt( + InputInterface $input, + string $optionName, + string $label, + string $placeholder = '', + bool $required = true, + ?string $default = null, + bool &$wasProvided = false + ): string { + /** @var ?string $value */ + $value = $input->getOption($optionName); + + if ($value !== null && $value !== '') { + $wasProvided = true; + + return $value; + } + + $wasProvided = false; + + return text( + label: $label, + placeholder: $placeholder, + default: $default ?? '', + required: $required + ); + } + + // + // Command Hints + // ------------------------------------------------------------------------------- + + /** + * Display a command replay hint showing how to run non-interactively. + * + * @param array $options Array of option name => value pairs + * @param array $provided Array of option name => was provided (true) or prompted (false) + */ + protected function showCommandHint(string $commandName, array $options, array $provided): void + { + $this->writeln('💡 Next time, run non-interactively:'); + $this->writeln(''); + + // Build command parts + $parts = [$commandName]; + + foreach ($options as $optionName => $value) { + $wasProvided = $provided[$optionName] ?? false; + $color = $wasProvided ? 'gray' : 'bright-yellow'; + + 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}"; + } + } + + $command = implode(' ', $parts); + $this->writeln(" {$command}"); + $this->writeln(''); + } +} diff --git a/tests/Unit/Contracts/BaseCommandTest.php b/tests/Unit/Contracts/BaseCommandTest.php index 3e4168a2..775c984a 100644 --- a/tests/Unit/Contracts/BaseCommandTest.php +++ b/tests/Unit/Contracts/BaseCommandTest.php @@ -68,7 +68,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ->toContain('Custom path to inventory.yml file'); }); - it('executes with proper status output', function (bool $hasEnvFile, string $expectedEnvMessage) { + it('executes with proper env and inventory status output', function (bool $hasEnvFile, string $expectedEnvMessage) { // ARRANGE $container = new Container(); $env = mockEnvService($hasEnvFile); @@ -91,56 +91,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'env file exists' => [true, 'Reading variables from'], 'no env file' => [false, 'No .env file found'], ]); - - it('displays correct env status messages for different scenarios', function (bool $hasEnvFile, string $envPattern) { - // ARRANGE - $container = new Container(); - $env = mockEnvService($hasEnvFile); - $inventory = mockInventoryService(true); - $command = new TestableBaseCommand($container, $env, $inventory); - $tester = new CommandTester($command); - - // ACT - $exitCode = $tester->execute([]); - $output = $tester->getDisplay(); - - // ASSERT - expect($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toMatch($envPattern) - ->and($output)->toContain('Reading inventory from'); - })->with([ - 'env file exists' => [true, '/Reading variables from/'], - 'no env file' => [false, '/No \\.env file found/'], - ]); - - it('hr displays separator line', function () { - // ARRANGE - $container = new Container(); - $env = mockEnvService(true); - $inventory = mockInventoryService(true); - - $command = new class ($container, $env, $inventory) extends BaseCommand { - protected function configure(): void - { - parent::configure(); - $this->setName('test-hr'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $this->hr(); - return Command::SUCCESS; - } - }; - - $tester = new CommandTester($command); - - // ACT - $tester->execute([]); - $output = $tester->getDisplay(); - - // ASSERT - expect($output)->toContain('╭───────') - ->and(strlen($output))->toBeGreaterThan(40); - }); }); diff --git a/tests/Unit/Traits/ConsoleOutputTraitTest.php b/tests/Unit/Traits/ConsoleOutputTraitTest.php new file mode 100644 index 00000000..16a53b30 --- /dev/null +++ b/tests/Unit/Traits/ConsoleOutputTraitTest.php @@ -0,0 +1,338 @@ +methodToTest = $method; + $this->testArgs = $args; + } + + protected function configure(): void + { + parent::configure(); + $this->setName('test-output')->setDescription('Test console output methods'); + $this->addOption('name', null, InputOption::VALUE_REQUIRED, 'Test name option'); + $this->addOption('host', null, InputOption::VALUE_REQUIRED, 'Test host option'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + if ($this->methodToTest !== '') { + match ($this->methodToTest) { + 'error' => $this->error(...$this->testArgs), + 'success' => $this->success(...$this->testArgs), + 'warning' => $this->warning(...$this->testArgs), + 'h1' => $this->h1(...$this->testArgs), + 'hr' => $this->hr(), + 'text' => $this->text(...$this->testArgs), + 'writeln' => $this->writeln(...$this->testArgs), + 'getOptionOrPrompt' => $this->testGetOptionOrPrompt($input), + 'showCommandHint' => $this->showCommandHint(...$this->testArgs), + default => null, + }; + } + + return Command::SUCCESS; + } + + private function testGetOptionOrPrompt(InputInterface $input): void + { + $wasProvided = false; + $result = $this->getOptionOrPrompt($input, 'name', 'Name:', wasProvided: $wasProvided); + $this->io->text("Result: {$result}, Provided: ".($wasProvided ? 'true' : 'false')); + } +} + +// +// Unit tests +// ------------------------------------------------------------------------------- + +describe('ConsoleOutputTrait', function () { + // + // Status Messages + + it('displays error message with red X symbol', function () { + // ARRANGE + $container = new Container(); + $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); + $command->setTestMethod('error', ['Connection failed']); + $tester = new CommandTester($command); + + // ACT + $tester->execute([]); + $output = $tester->getDisplay(); + + // ASSERT + expect($output)->toContain('✗') + ->and($output)->toContain('Connection failed'); + }); + + it('displays error message with optional tip', function () { + // ARRANGE + $container = new Container(); + $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); + $command->setTestMethod('error', ['Connection failed', 'Check your SSH key']); + $tester = new CommandTester($command); + + // ACT + $tester->execute([]); + $output = $tester->getDisplay(); + + // ASSERT + expect($output)->toContain('✗') + ->and($output)->toContain('Connection failed') + ->and($output)->toContain('Tip:') + ->and($output)->toContain('Check your SSH key'); + }); + + it('displays success message with green checkmark', function () { + // ARRANGE + $container = new Container(); + $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); + $command->setTestMethod('success', ['Server added successfully']); + $tester = new CommandTester($command); + + // ACT + $tester->execute([]); + $output = $tester->getDisplay(); + + // ASSERT + expect($output)->toContain('✓') + ->and($output)->toContain('Server added successfully'); + }); + + it('displays warning message with yellow warning symbol', function () { + // ARRANGE + $container = new Container(); + $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); + $command->setTestMethod('warning', ['Skipping connection check']); + $tester = new CommandTester($command); + + // ACT + $tester->execute([]); + $output = $tester->getDisplay(); + + // ASSERT + expect($output)->toContain('⚠') + ->and($output)->toContain('Skipping connection check'); + }); + + // + // Output Formatting + + it('displays heading with icon', function () { + // ARRANGE + $container = new Container(); + $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); + $command->setTestMethod('h1', ['Server Configuration']); + $tester = new CommandTester($command); + + // ACT + $tester->execute([]); + $output = $tester->getDisplay(); + + // ASSERT + expect($output)->toContain('▸') + ->and($output)->toContain('Server Configuration'); + }); + + it('displays separator line with box-drawing characters', function () { + // ARRANGE + $container = new Container(); + $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); + $command->setTestMethod('hr'); + $tester = new CommandTester($command); + + // ACT + $tester->execute([]); + $output = $tester->getDisplay(); + + // ASSERT + expect($output)->toContain('╭───────') + ->and(strlen($output))->toBeGreaterThan(40); + }); + + it('writes text with proper indentation', function () { + // ARRANGE + $container = new Container(); + $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); + $command->setTestMethod('text', ['Simple text output']); + $tester = new CommandTester($command); + + // ACT + $tester->execute([]); + $output = $tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Simple text output'); + }); + + it('writes multiple lines of text', function () { + // ARRANGE + $container = new Container(); + $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); + $command->setTestMethod('text', [['Line 1', 'Line 2', 'Line 3']]); + $tester = new CommandTester($command); + + // ACT + $tester->execute([]); + $output = $tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Line 1') + ->and($output)->toContain('Line 2') + ->and($output)->toContain('Line 3'); + }); + + it('writes single line', function () { + // ARRANGE + $container = new Container(); + $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); + $command->setTestMethod('writeln', ['Output line']); + $tester = new CommandTester($command); + + // ACT + $tester->execute([]); + $output = $tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Output line'); + }); + + it('writes multiple lines', function () { + // ARRANGE + $container = new Container(); + $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); + $command->setTestMethod('writeln', [['First line', 'Second line']]); + $tester = new CommandTester($command); + + // ACT + $tester->execute([]); + $output = $tester->getDisplay(); + + // ASSERT + expect($output)->toContain('First line') + ->and($output)->toContain('Second line'); + }); + + // + // User Input Helpers + + it('gets option value when provided via CLI', function () { + // ARRANGE + $container = new Container(); + $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); + $command->setTestMethod('getOptionOrPrompt'); + $tester = new CommandTester($command); + + // ACT + $tester->execute(['--name' => 'production']); + $output = $tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Result: production') + ->and($output)->toContain('Provided: true'); + }); + + // + // Command Hints + + it('displays command hint for non-interactive execution', function () { + // ARRANGE + $container = new Container(); + $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); + $command->setTestMethod('showCommandHint', [ + 'server:add', + ['name' => 'prod-server', 'host' => '192.168.1.100', 'yes' => true], + ['name' => false, 'host' => false, 'yes' => true], + ]); + $tester = new CommandTester($command); + + // ACT + $tester->execute([]); + $output = $tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Next time, run non-interactively:') + ->and($output)->toContain('server:add') + ->and($output)->toContain('--name') + ->and($output)->toContain('--host') + ->and($output)->toContain('--yes'); + }); + + it('highlights prompted options differently in command hint', function () { + // ARRANGE + $container = new Container(); + $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); + $command->setTestMethod('showCommandHint', [ + 'server:add', + ['name' => 'prod-server', 'host' => '192.168.1.100'], + ['name' => true, 'host' => false], + ]); + $tester = new CommandTester($command); + + // ACT + $tester->execute([]); + $output = $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 + $container = new Container(); + $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); + $command->setTestMethod('showCommandHint', [ + 'server:add', + ['name' => 'prod-server', 'host' => null, 'port' => ''], + ['name' => true, 'host' => false, 'port' => false], + ]); + $tester = new CommandTester($command); + + // ACT + $tester->execute([]); + $output = $tester->getDisplay(); + + // ASSERT + expect($output)->toContain('--name') + ->and($output)->not->toContain('--host') + ->and($output)->not->toContain('--port'); + }); +}); From 396c2aaf04ceb2693abdc73ffabf7476db8b3ae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Thu, 2 Oct 2025 23:04:04 +0300 Subject: [PATCH 2/8] test(console): refactor ConsoleOutputTraitTest with beforeEach Eliminate repetitive setup code by extracting command and tester initialization to a beforeEach() hook. Improvements: - Reduce test file size from 339 to 302 lines (-37 lines, 11% reduction) - Improve test:source ratio from 1.695:1 to 1.51:1 - Enhance maintainability with single point of setup - Follow Pest best practices for test organization - All 14 tests pass with no regressions --- tests/Unit/Traits/ConsoleOutputTraitTest.php | 132 +++++++------------ 1 file changed, 48 insertions(+), 84 deletions(-) diff --git a/tests/Unit/Traits/ConsoleOutputTraitTest.php b/tests/Unit/Traits/ConsoleOutputTraitTest.php index 16a53b30..e88c2a32 100644 --- a/tests/Unit/Traits/ConsoleOutputTraitTest.php +++ b/tests/Unit/Traits/ConsoleOutputTraitTest.php @@ -81,19 +81,22 @@ private function testGetOptionOrPrompt(InputInterface $input): void // ------------------------------------------------------------------------------- describe('ConsoleOutputTrait', function () { + beforeEach(function () { + $container = new Container(); + $this->command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); + $this->tester = new CommandTester($this->command); + }); + // // Status Messages it('displays error message with red X symbol', function () { // ARRANGE - $container = new Container(); - $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); - $command->setTestMethod('error', ['Connection failed']); - $tester = new CommandTester($command); + $this->command->setTestMethod('error', ['Connection failed']); // ACT - $tester->execute([]); - $output = $tester->getDisplay(); + $this->tester->execute([]); + $output = $this->tester->getDisplay(); // ASSERT expect($output)->toContain('✗') @@ -102,14 +105,11 @@ private function testGetOptionOrPrompt(InputInterface $input): void it('displays error message with optional tip', function () { // ARRANGE - $container = new Container(); - $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); - $command->setTestMethod('error', ['Connection failed', 'Check your SSH key']); - $tester = new CommandTester($command); + $this->command->setTestMethod('error', ['Connection failed', 'Check your SSH key']); // ACT - $tester->execute([]); - $output = $tester->getDisplay(); + $this->tester->execute([]); + $output = $this->tester->getDisplay(); // ASSERT expect($output)->toContain('✗') @@ -120,14 +120,11 @@ private function testGetOptionOrPrompt(InputInterface $input): void it('displays success message with green checkmark', function () { // ARRANGE - $container = new Container(); - $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); - $command->setTestMethod('success', ['Server added successfully']); - $tester = new CommandTester($command); + $this->command->setTestMethod('success', ['Server added successfully']); // ACT - $tester->execute([]); - $output = $tester->getDisplay(); + $this->tester->execute([]); + $output = $this->tester->getDisplay(); // ASSERT expect($output)->toContain('✓') @@ -136,14 +133,11 @@ private function testGetOptionOrPrompt(InputInterface $input): void it('displays warning message with yellow warning symbol', function () { // ARRANGE - $container = new Container(); - $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); - $command->setTestMethod('warning', ['Skipping connection check']); - $tester = new CommandTester($command); + $this->command->setTestMethod('warning', ['Skipping connection check']); // ACT - $tester->execute([]); - $output = $tester->getDisplay(); + $this->tester->execute([]); + $output = $this->tester->getDisplay(); // ASSERT expect($output)->toContain('⚠') @@ -155,14 +149,11 @@ private function testGetOptionOrPrompt(InputInterface $input): void it('displays heading with icon', function () { // ARRANGE - $container = new Container(); - $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); - $command->setTestMethod('h1', ['Server Configuration']); - $tester = new CommandTester($command); + $this->command->setTestMethod('h1', ['Server Configuration']); // ACT - $tester->execute([]); - $output = $tester->getDisplay(); + $this->tester->execute([]); + $output = $this->tester->getDisplay(); // ASSERT expect($output)->toContain('▸') @@ -171,14 +162,11 @@ private function testGetOptionOrPrompt(InputInterface $input): void it('displays separator line with box-drawing characters', function () { // ARRANGE - $container = new Container(); - $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); - $command->setTestMethod('hr'); - $tester = new CommandTester($command); + $this->command->setTestMethod('hr'); // ACT - $tester->execute([]); - $output = $tester->getDisplay(); + $this->tester->execute([]); + $output = $this->tester->getDisplay(); // ASSERT expect($output)->toContain('╭───────') @@ -187,14 +175,11 @@ private function testGetOptionOrPrompt(InputInterface $input): void it('writes text with proper indentation', function () { // ARRANGE - $container = new Container(); - $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); - $command->setTestMethod('text', ['Simple text output']); - $tester = new CommandTester($command); + $this->command->setTestMethod('text', ['Simple text output']); // ACT - $tester->execute([]); - $output = $tester->getDisplay(); + $this->tester->execute([]); + $output = $this->tester->getDisplay(); // ASSERT expect($output)->toContain('Simple text output'); @@ -202,14 +187,11 @@ private function testGetOptionOrPrompt(InputInterface $input): void it('writes multiple lines of text', function () { // ARRANGE - $container = new Container(); - $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); - $command->setTestMethod('text', [['Line 1', 'Line 2', 'Line 3']]); - $tester = new CommandTester($command); + $this->command->setTestMethod('text', [['Line 1', 'Line 2', 'Line 3']]); // ACT - $tester->execute([]); - $output = $tester->getDisplay(); + $this->tester->execute([]); + $output = $this->tester->getDisplay(); // ASSERT expect($output)->toContain('Line 1') @@ -219,14 +201,11 @@ private function testGetOptionOrPrompt(InputInterface $input): void it('writes single line', function () { // ARRANGE - $container = new Container(); - $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); - $command->setTestMethod('writeln', ['Output line']); - $tester = new CommandTester($command); + $this->command->setTestMethod('writeln', ['Output line']); // ACT - $tester->execute([]); - $output = $tester->getDisplay(); + $this->tester->execute([]); + $output = $this->tester->getDisplay(); // ASSERT expect($output)->toContain('Output line'); @@ -234,14 +213,11 @@ private function testGetOptionOrPrompt(InputInterface $input): void it('writes multiple lines', function () { // ARRANGE - $container = new Container(); - $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); - $command->setTestMethod('writeln', [['First line', 'Second line']]); - $tester = new CommandTester($command); + $this->command->setTestMethod('writeln', [['First line', 'Second line']]); // ACT - $tester->execute([]); - $output = $tester->getDisplay(); + $this->tester->execute([]); + $output = $this->tester->getDisplay(); // ASSERT expect($output)->toContain('First line') @@ -253,14 +229,11 @@ private function testGetOptionOrPrompt(InputInterface $input): void it('gets option value when provided via CLI', function () { // ARRANGE - $container = new Container(); - $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); - $command->setTestMethod('getOptionOrPrompt'); - $tester = new CommandTester($command); + $this->command->setTestMethod('getOptionOrPrompt'); // ACT - $tester->execute(['--name' => 'production']); - $output = $tester->getDisplay(); + $this->tester->execute(['--name' => 'production']); + $output = $this->tester->getDisplay(); // ASSERT expect($output)->toContain('Result: production') @@ -272,18 +245,15 @@ private function testGetOptionOrPrompt(InputInterface $input): void it('displays command hint for non-interactive execution', function () { // ARRANGE - $container = new Container(); - $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); - $command->setTestMethod('showCommandHint', [ + $this->command->setTestMethod('showCommandHint', [ 'server:add', ['name' => 'prod-server', 'host' => '192.168.1.100', 'yes' => true], ['name' => false, 'host' => false, 'yes' => true], ]); - $tester = new CommandTester($command); // ACT - $tester->execute([]); - $output = $tester->getDisplay(); + $this->tester->execute([]); + $output = $this->tester->getDisplay(); // ASSERT expect($output)->toContain('Next time, run non-interactively:') @@ -295,18 +265,15 @@ private function testGetOptionOrPrompt(InputInterface $input): void it('highlights prompted options differently in command hint', function () { // ARRANGE - $container = new Container(); - $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); - $command->setTestMethod('showCommandHint', [ + $this->command->setTestMethod('showCommandHint', [ 'server:add', ['name' => 'prod-server', 'host' => '192.168.1.100'], ['name' => true, 'host' => false], ]); - $tester = new CommandTester($command); // ACT - $tester->execute([]); - $output = $tester->getDisplay(); + $this->tester->execute([]); + $output = $this->tester->getDisplay(); // ASSERT expect($output)->toContain('--name') @@ -317,18 +284,15 @@ private function testGetOptionOrPrompt(InputInterface $input): void it('skips null and empty values in command hint', function () { // ARRANGE - $container = new Container(); - $command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); - $command->setTestMethod('showCommandHint', [ + $this->command->setTestMethod('showCommandHint', [ 'server:add', ['name' => 'prod-server', 'host' => null, 'port' => ''], ['name' => true, 'host' => false, 'port' => false], ]); - $tester = new CommandTester($command); // ACT - $tester->execute([]); - $output = $tester->getDisplay(); + $this->tester->execute([]); + $output = $this->tester->getDisplay(); // ASSERT expect($output)->toContain('--name') From f684b9231808c8a1121b6c4ecc757ac2310cfe15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Fri, 3 Oct 2025 00:33:45 +0300 Subject: [PATCH 3/8] refactor(console): extract input helpers to dedicated trait Move getOptionOrPrompt and related input logic from ConsoleOutputTrait to new ConsoleInputTrait. Update BaseCommand to use both traits and set input/output properties. Simplify output methods and add info/note helpers. Update documentation example to reflect new method signature. --- .cursor/rules/03-commands.mdc | 4 +- app/Contracts/BaseCommand.php | 19 +++- app/Traits/ConsoleInputTrait.php | 56 +++++++++++ app/Traits/ConsoleOutputTrait.php | 148 +++++++++++------------------- 4 files changed, 125 insertions(+), 102 deletions(-) create mode 100644 app/Traits/ConsoleInputTrait.php diff --git a/.cursor/rules/03-commands.mdc b/.cursor/rules/03-commands.mdc index 807d9ecd..90974fee 100644 --- a/.cursor/rules/03-commands.mdc +++ b/.cursor/rules/03-commands.mdc @@ -181,9 +181,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Track which options were provided vs prompted $provided = []; - // Use BaseCommand helper method + // Use BaseCommand helper method (uses $this->input internally) $name = $this->getOptionOrPrompt( - $input, 'name', 'Server name:', placeholder: 'production-web-01', @@ -191,7 +190,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); $host = $this->getOptionOrPrompt( - $input, 'host', 'Host/IP address:', placeholder: '192.168.1.100', diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php index a7dab7ad..f2b0f742 100644 --- a/app/Contracts/BaseCommand.php +++ b/app/Contracts/BaseCommand.php @@ -7,6 +7,7 @@ use Bigpixelrocket\DeployerPHP\Container; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use Bigpixelrocket\DeployerPHP\Traits\ConsoleInputTrait; use Bigpixelrocket\DeployerPHP\Traits\ConsoleOutputTrait; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -19,8 +20,11 @@ */ abstract class BaseCommand extends Command { + use ConsoleInputTrait; use ConsoleOutputTrait; + protected InputInterface $input; + protected OutputInterface $output; protected SymfonyStyle $io; public function __construct( @@ -32,7 +36,7 @@ public function __construct( } // - // Common config + // Configuration // ------------------------------------------------------------------------------- /** @@ -58,12 +62,14 @@ protected function configure(): void } /** - * Initialize IO and services early. + * Initialize IO and services. */ protected function initialize(InputInterface $input, OutputInterface $output): void { parent::initialize($input, $output); + $this->input = $input; + $this->output = $output; $this->io = new SymfonyStyle($input, $output); // @@ -83,11 +89,18 @@ protected function initialize(InputInterface $input, OutputInterface $output): v $this->inventory->loadInventoryFile(); } + // + // Execution + // ------------------------------------------------------------------------------- + /** - * Display env and inventory statuses. + * Common execution logic. */ protected function execute(InputInterface $input, OutputInterface $output): int { + // + // Display env and inventory statuses + $envStatus = $this->env->getEnvFileStatus(); $color = str_starts_with($envStatus, 'No .env') ? 'yellow' : 'gray'; $this->writeln([ diff --git a/app/Traits/ConsoleInputTrait.php b/app/Traits/ConsoleInputTrait.php new file mode 100644 index 00000000..7d66dc08 --- /dev/null +++ b/app/Traits/ConsoleInputTrait.php @@ -0,0 +1,56 @@ +input->getOption($optionName); + + if (is_string($value) && $value !== '') { + $wasProvided = true; + + return $value; + } + + $wasProvided = false; + + return text( + label: $label, + placeholder: $placeholder, + default: $default, + required: $required + ); + } +} diff --git a/app/Traits/ConsoleOutputTrait.php b/app/Traits/ConsoleOutputTrait.php index 083905b8..e3847e98 100644 --- a/app/Traits/ConsoleOutputTrait.php +++ b/app/Traits/ConsoleOutputTrait.php @@ -4,153 +4,109 @@ namespace Bigpixelrocket\DeployerPHP\Traits; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; - -use function Laravel\Prompts\text; - /** - * Console output formatting methods for beautiful TUI. - * - * Provides consistent styling, status messages, and command hints. + * Console output formatting methods. * - * Requires the using class to have: - * - protected SymfonyStyle $io + * Requires the using class to have a `protected SymfonyStyle $io` property. */ trait ConsoleOutputTrait { // - // Status Messages + // Raw output // ------------------------------------------------------------------------------- /** - * Display an error message with red X and optional tip. + * Write-out multiple lines. + * + * @param array $lines */ - protected function error(string $message, ?string $tip = null): void + protected function writeln(string|array $lines): void { - $output = [ - "✗ {$message}", - '', - ]; - - if ($tip !== null) { - $output[] = "Tip: {$tip}"; - $output[] = ''; + $writeLines = is_array($lines) ? $lines : [$lines]; + foreach ($writeLines as $line) { + $this->io->writeln(' '.$line); } - - $this->writeln($output); } + // + // Message helpers + // ------------------------------------------------------------------------------- + /** - * Display a success message with green checkmark. + * Display a plain text message. */ - protected function success(string $message): void + protected function text(string $message): void { - $this->writeln([ - "✓ {$message}", - '', - ]); + $this->writeln($message); } /** - * Display a warning message with yellow warning symbol. + * Display an info message with cyan info symbol. */ - protected function warning(string $message): void + protected function info(string $message): void { - $this->writeln([ - "⚠ {$message}", - '', - ]); + $this->writeln("ℹ {$message}"); } - // - // Output Formatting - // ------------------------------------------------------------------------------- - /** - * Write-out a heading. + * Display a note message with cyan info symbol. */ - protected function h1(string $text): void + protected function note(string $message): void { - $this->writeln(''.$text.''); + $this->info($message); } /** - * Write-out a separator line. + * Display a success message with green checkmark. */ - protected function hr(): void + protected function success(string $message): void { - $this->writeln([ - '╭──────────────────────────────────────────', - '', - ]); + $this->writeln("✓ {$message}"); } /** - * Write-out styled text lines. - * - * @param array $lines + * Display a warning message with yellow warning symbol. */ - protected function text(string|array $lines): void + protected function warning(string $message): void { - $writeLines = is_array($lines) ? $lines : [$lines]; - foreach ($writeLines as $line) { - $this->io->text(' '.$line); - } + $this->writeln("⚠ {$message}"); } /** - * Write-out multiple lines. - * - * @param array $lines + * Display an error message with red X and optional tip. */ - protected function writeln(string|array $lines): void + protected function error(string $message, ?string $tip = null): void { - $writeLines = is_array($lines) ? $lines : [$lines]; - foreach ($writeLines as $line) { - $this->io->writeln(' '.$line); + $output = ["✗ {$message}"]; + + if ($tip !== null) { + $output[] = "Tip: {$tip}"; } + + $this->writeln($output); } // - // User Input Helpers + // Heading and separator // ------------------------------------------------------------------------------- /** - * Get option value or prompt user interactively. - * - * Checks if an option was provided via CLI. If yes, returns the value and sets - * wasProvided to true. If not, prompts user interactively and sets wasProvided to false. - * - * @param bool $wasProvided Set to true if option was provided, false if prompted - * @return string The option value (from CLI or prompt) + * Write-out a heading. */ - protected function getOptionOrPrompt( - InputInterface $input, - string $optionName, - string $label, - string $placeholder = '', - bool $required = true, - ?string $default = null, - bool &$wasProvided = false - ): string { - /** @var ?string $value */ - $value = $input->getOption($optionName); - - if ($value !== null && $value !== '') { - $wasProvided = true; - - return $value; - } - - $wasProvided = false; + protected function h1(string $text): void + { + $this->writeln(''.$text.''); + } - return text( - label: $label, - placeholder: $placeholder, - default: $default ?? '', - required: $required - ); + /** + * Write-out a separator line. + */ + protected function hr(): void + { + $this->writeln([ + '╭──────────────────────────────────────────', + '', + ]); } // From 268ac6e7994c4d50be8cd4119d94ada63c8fa310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Fri, 3 Oct 2025 00:33:47 +0300 Subject: [PATCH 4/8] test(console): add ConsoleInputTrait tests and refactor OutputTrait tests Introduce TestConsoleCommand fixture for trait testing. Add unit tests for input trait's getOptionOrPrompt method. Update output trait tests to use the shared fixture and remove the now-migrated input-related tests. --- tests/Fixtures/TestConsoleCommand.php | 80 +++++++++++++ tests/Unit/Traits/ConsoleInputTraitTest.php | 35 ++++++ tests/Unit/Traits/ConsoleOutputTraitTest.php | 116 +------------------ 3 files changed, 117 insertions(+), 114 deletions(-) create mode 100644 tests/Fixtures/TestConsoleCommand.php create mode 100644 tests/Unit/Traits/ConsoleInputTraitTest.php diff --git a/tests/Fixtures/TestConsoleCommand.php b/tests/Fixtures/TestConsoleCommand.php new file mode 100644 index 00000000..48c14222 --- /dev/null +++ b/tests/Fixtures/TestConsoleCommand.php @@ -0,0 +1,80 @@ +methodToTest = $method; + $this->testArgs = $args; + } + + protected function configure(): void + { + parent::configure(); + $this->setName('test-console')->setDescription('Test console trait methods'); + $this->addOption('name', null, InputOption::VALUE_REQUIRED, 'Test name option'); + $this->addOption('host', null, InputOption::VALUE_REQUIRED, 'Test host option'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + if ($this->methodToTest !== '') { + match ($this->methodToTest) { + '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), + 'getOptionOrPrompt' => $this->testGetOptionOrPrompt(), + default => null, + }; + } + + return Command::SUCCESS; + } + + /** + * Test helper for getOptionOrPrompt method. + */ + private function testGetOptionOrPrompt(): void + { + $wasProvided = false; + $result = $this->getOptionOrPrompt('name', 'Name:', wasProvided: $wasProvided); + $this->io->text("Result: {$result}, Provided: ".($wasProvided ? 'true' : 'false')); + } +} diff --git a/tests/Unit/Traits/ConsoleInputTraitTest.php b/tests/Unit/Traits/ConsoleInputTraitTest.php new file mode 100644 index 00000000..41d2311d --- /dev/null +++ b/tests/Unit/Traits/ConsoleInputTraitTest.php @@ -0,0 +1,35 @@ +command = new TestConsoleCommand($container, mockEnvService(true), mockInventoryService(true)); + $this->tester = new CommandTester($this->command); + }); + + // + // getOptionOrPrompt + + it('returns option value and sets wasProvided to true when option provided', function () { + // ARRANGE + $this->command->setTestMethod('getOptionOrPrompt'); + + // ACT + $this->tester->execute(['--name' => 'production']); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Result: production') + ->and($output)->toContain('Provided: true'); + }); +}); diff --git a/tests/Unit/Traits/ConsoleOutputTraitTest.php b/tests/Unit/Traits/ConsoleOutputTraitTest.php index e88c2a32..e0934f2d 100644 --- a/tests/Unit/Traits/ConsoleOutputTraitTest.php +++ b/tests/Unit/Traits/ConsoleOutputTraitTest.php @@ -5,85 +5,15 @@ namespace Bigpixelrocket\DeployerPHP\Tests\Unit\Traits; use Bigpixelrocket\DeployerPHP\Container; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; -use Bigpixelrocket\DeployerPHP\Services\EnvService; -use Bigpixelrocket\DeployerPHP\Services\InventoryService; -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 Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand; use Symfony\Component\Console\Tester\CommandTester; require_once __DIR__.'/../../TestHelpers.php'; -// -// Test fixtures -// ------------------------------------------------------------------------------- - -class TestConsoleOutputCommand extends BaseCommand -{ - private string $methodToTest = ''; - - private array $testArgs = []; - - public function __construct( - Container $container, - EnvService $env, - InventoryService $inventory, - ) { - parent::__construct($container, $env, $inventory); - } - - public function setTestMethod(string $method, array $args = []): void - { - $this->methodToTest = $method; - $this->testArgs = $args; - } - - protected function configure(): void - { - parent::configure(); - $this->setName('test-output')->setDescription('Test console output methods'); - $this->addOption('name', null, InputOption::VALUE_REQUIRED, 'Test name option'); - $this->addOption('host', null, InputOption::VALUE_REQUIRED, 'Test host option'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - if ($this->methodToTest !== '') { - match ($this->methodToTest) { - 'error' => $this->error(...$this->testArgs), - 'success' => $this->success(...$this->testArgs), - 'warning' => $this->warning(...$this->testArgs), - 'h1' => $this->h1(...$this->testArgs), - 'hr' => $this->hr(), - 'text' => $this->text(...$this->testArgs), - 'writeln' => $this->writeln(...$this->testArgs), - 'getOptionOrPrompt' => $this->testGetOptionOrPrompt($input), - 'showCommandHint' => $this->showCommandHint(...$this->testArgs), - default => null, - }; - } - - return Command::SUCCESS; - } - - private function testGetOptionOrPrompt(InputInterface $input): void - { - $wasProvided = false; - $result = $this->getOptionOrPrompt($input, 'name', 'Name:', wasProvided: $wasProvided); - $this->io->text("Result: {$result}, Provided: ".($wasProvided ? 'true' : 'false')); - } -} - -// -// Unit tests -// ------------------------------------------------------------------------------- - describe('ConsoleOutputTrait', function () { beforeEach(function () { $container = new Container(); - $this->command = new TestConsoleOutputCommand($container, mockEnvService(true), mockInventoryService(true)); + $this->command = new TestConsoleCommand($container, mockEnvService(true), mockInventoryService(true)); $this->tester = new CommandTester($this->command); }); @@ -173,32 +103,6 @@ private function testGetOptionOrPrompt(InputInterface $input): void ->and(strlen($output))->toBeGreaterThan(40); }); - it('writes text with proper indentation', function () { - // ARRANGE - $this->command->setTestMethod('text', ['Simple text output']); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('Simple text output'); - }); - - it('writes multiple lines of text', function () { - // ARRANGE - $this->command->setTestMethod('text', [['Line 1', 'Line 2', 'Line 3']]); - - // ACT - $this->tester->execute([]); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('Line 1') - ->and($output)->toContain('Line 2') - ->and($output)->toContain('Line 3'); - }); - it('writes single line', function () { // ARRANGE $this->command->setTestMethod('writeln', ['Output line']); @@ -224,22 +128,6 @@ private function testGetOptionOrPrompt(InputInterface $input): void ->and($output)->toContain('Second line'); }); - // - // User Input Helpers - - it('gets option value when provided via CLI', function () { - // ARRANGE - $this->command->setTestMethod('getOptionOrPrompt'); - - // ACT - $this->tester->execute(['--name' => 'production']); - $output = $this->tester->getDisplay(); - - // ASSERT - expect($output)->toContain('Result: production') - ->and($output)->toContain('Provided: true'); - }); - // // Command Hints From a1c28c51a0f34d5cb2eb6b38f1ba42e55c9eba5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Fri, 3 Oct 2025 00:33:49 +0300 Subject: [PATCH 5/8] chore(style): enable ordered imports and unused import removal Update pint.json to include ordered_imports (alpha sort) and no_unused_imports rules as per user preference. Reorder use statements in affected test files to apply the new rules. --- pint.json | 8 +++++++- tests/Integration/SymfonyAppTest.php | 4 ++-- tests/Unit/ContainerTest.php | 16 ++++++++-------- tests/Unit/Contracts/BaseCommandTest.php | 2 +- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/pint.json b/pint.json index ea5e72cf..bde1c173 100644 --- a/pint.json +++ b/pint.json @@ -1,3 +1,9 @@ { - "preset": "psr12" + "preset": "psr12", + "rules": { + "ordered_imports": { + "sort_algorithm": "alpha" + }, + "no_unused_imports": true + } } diff --git a/tests/Integration/SymfonyAppTest.php b/tests/Integration/SymfonyAppTest.php index ec3484f6..4ea25e93 100644 --- a/tests/Integration/SymfonyAppTest.php +++ b/tests/Integration/SymfonyAppTest.php @@ -4,10 +4,10 @@ use Bigpixelrocket\DeployerPHP\Container; use Bigpixelrocket\DeployerPHP\SymfonyApp; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; -use Symfony\Component\Console\Exception\CommandNotFoundException; -use Symfony\Component\Console\Command\Command; describe('SymfonyApp', function () { diff --git a/tests/Unit/ContainerTest.php b/tests/Unit/ContainerTest.php index 1437c6fc..21685cb2 100644 --- a/tests/Unit/ContainerTest.php +++ b/tests/Unit/ContainerTest.php @@ -5,20 +5,20 @@ namespace Bigpixelrocket\DeployerPHP\Tests\Unit; use Bigpixelrocket\DeployerPHP\Container; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\SimpleService; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\AbstractClass; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\CircularA; use Bigpixelrocket\DeployerPHP\Tests\Fixtures\NoConstructorService; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithMultipleDeps; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\PrivateConstructor; use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithDefaults; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithIntersectionType; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithMultipleDeps; use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithOptionalClassDep; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\CircularA; use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithScalarParam; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestInterface; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\AbstractClass; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\PrivateConstructor; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithUnionType; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithIntersectionType; use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithUnionAndCircular; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithUnionType; use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithUnresolvableDependency; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\SimpleService; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestInterface; // // Load test fixtures diff --git a/tests/Unit/Contracts/BaseCommandTest.php b/tests/Unit/Contracts/BaseCommandTest.php index 775c984a..e92ba154 100644 --- a/tests/Unit/Contracts/BaseCommandTest.php +++ b/tests/Unit/Contracts/BaseCommandTest.php @@ -4,8 +4,8 @@ namespace Bigpixelrocket\DeployerPHP\Tests\Unit\Contracts; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; use Bigpixelrocket\DeployerPHP\Container; +use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; use Symfony\Component\Console\Command\Command; From 29413ba7d214f82ea6637b0d6d2858a4e5ab7de7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Fri, 3 Oct 2025 00:47:05 +0300 Subject: [PATCH 6/8] docs(console): clarify trait organization and method placement Add comprehensive documentation on when to add methods to ConsoleOutputTrait, ConsoleInputTrait, or BaseCommand. Include integration points for both output and input traits. Enhance docblocks in BaseCommand and ConsoleOutputTrait to reflect trait usage patterns. --- .cursor/rules/03-commands.mdc | 22 +++++++++++++++++++++- app/Contracts/BaseCommand.php | 3 +++ app/Traits/ConsoleOutputTrait.php | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.cursor/rules/03-commands.mdc b/.cursor/rules/03-commands.mdc index 90974fee..91b1c3e7 100644 --- a/.cursor/rules/03-commands.mdc +++ b/.cursor/rules/03-commands.mdc @@ -59,11 +59,31 @@ protected function success(string $message): void { **Integration Points:** - Base implementation: [BaseCommand.php](mdc:app/Contracts/BaseCommand.php) -- Output methods: `writeln()`, `text()`, `hr()`, `h1()` +- Output trait: [ConsoleOutputTrait.php](mdc:app/Traits/ConsoleOutputTrait.php) +- Input trait: [ConsoleInputTrait.php](mdc:app/Traits/ConsoleInputTrait.php) +- Output methods: `writeln()`, `text()`, `info()`, `note()`, `hr()`, `h1()` - Status methods: `success()`, `error()`, `warning()` - Input methods: `getOptionOrPrompt()` - Helper methods: `showCommandHint()` +**When to Add New Methods:** + +Console functionality is organized using traits for reusability: + +- **ConsoleOutputTrait:** Add output/formatting methods that format and display text + - Examples: status messages, headings, separators, formatting helpers + - All methods should work with `$this->io` (SymfonyStyle) + +- **ConsoleInputTrait:** Add input gathering methods that collect user data + - Examples: prompt helpers, option validators, input transformers + - All methods should work with `$this->input` (InputInterface) + +- **BaseCommand:** Add only shared initialization, configuration, or orchestration logic + - Examples: service initialization, common options, execution flow + - NOT for individual I/O operations + +This separation ensures console I/O methods remain reusable across different command contexts. + ### 📣 Status Message Helpers **Use status helpers for consistent success/error/warning messages:** diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php index f2b0f742..06a08477 100644 --- a/app/Contracts/BaseCommand.php +++ b/app/Contracts/BaseCommand.php @@ -17,6 +17,9 @@ /** * 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. */ abstract class BaseCommand extends Command { diff --git a/app/Traits/ConsoleOutputTrait.php b/app/Traits/ConsoleOutputTrait.php index e3847e98..974f8dbe 100644 --- a/app/Traits/ConsoleOutputTrait.php +++ b/app/Traits/ConsoleOutputTrait.php @@ -49,7 +49,7 @@ protected function info(string $message): void } /** - * Display a note message with cyan info symbol. + * Display a note message with cyan info symbol (alias for info). */ protected function note(string $message): void { From 7ce16925040977c9735b9c39df2b99bb72cbaa0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Fri, 3 Oct 2025 00:47:15 +0300 Subject: [PATCH 7/8] test(console): add coverage for ConsoleOutputTrait basic methods Add tests for text(), info(), and note() methods in ConsoleOutputTrait. Extend TestConsoleCommand fixture to support testing these basic output methods. --- tests/Fixtures/TestConsoleCommand.php | 3 ++ tests/Unit/Traits/ConsoleOutputTraitTest.php | 41 ++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/tests/Fixtures/TestConsoleCommand.php b/tests/Fixtures/TestConsoleCommand.php index 48c14222..65c3d099 100644 --- a/tests/Fixtures/TestConsoleCommand.php +++ b/tests/Fixtures/TestConsoleCommand.php @@ -53,6 +53,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int { if ($this->methodToTest !== '') { match ($this->methodToTest) { + 'text' => $this->text(...$this->testArgs), + 'info' => $this->info(...$this->testArgs), + 'note' => $this->note(...$this->testArgs), 'error' => $this->error(...$this->testArgs), 'success' => $this->success(...$this->testArgs), 'warning' => $this->warning(...$this->testArgs), diff --git a/tests/Unit/Traits/ConsoleOutputTraitTest.php b/tests/Unit/Traits/ConsoleOutputTraitTest.php index e0934f2d..e6f09a0c 100644 --- a/tests/Unit/Traits/ConsoleOutputTraitTest.php +++ b/tests/Unit/Traits/ConsoleOutputTraitTest.php @@ -17,9 +17,50 @@ $this->tester = new CommandTester($this->command); }); + // + // Basic Output + + it('displays plain text message', function () { + // ARRANGE + $this->command->setTestMethod('text', ['Plain text message']); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Plain text message'); + }); + // // Status Messages + 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 note message with cyan info symbol', function () { + // ARRANGE + $this->command->setTestMethod('note', ['Note message']); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('ℹ') + ->and($output)->toContain('Note message'); + }); + it('displays error message with red X symbol', function () { // ARRANGE $this->command->setTestMethod('error', ['Connection failed']); From 35a134ac59f88c4da11422c8da6ab72094895806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Fri, 3 Oct 2025 00:57:17 +0300 Subject: [PATCH 8/8] refactor: use BaseCommand text() wrapper instead of raw io->text() in test fixture - Replace direct io->text() call with text() wrapper method - Ensures test fixture follows architectural pattern for console output - Aligns with mandatory rule: use BaseCommand methods exclusively --- tests/Unit/Contracts/BaseCommandTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Contracts/BaseCommandTest.php b/tests/Unit/Contracts/BaseCommandTest.php index e92ba154..2d4abdfe 100644 --- a/tests/Unit/Contracts/BaseCommandTest.php +++ b/tests/Unit/Contracts/BaseCommandTest.php @@ -39,7 +39,7 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $result = parent::execute($input, $output); - $this->io->text('Test command executed successfully'); + $this->text('Test command executed successfully'); return $result; } }