diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php index 972c161a..b7f85fd3 100644 --- a/app/Contracts/BaseCommand.php +++ b/app/Contracts/BaseCommand.php @@ -8,6 +8,7 @@ use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use Bigpixelrocket\DeployerPHP\Services\PrompterService; use Bigpixelrocket\DeployerPHP\Services\SSHService; use Bigpixelrocket\DeployerPHP\Traits\ConsoleInputTrait; use Bigpixelrocket\DeployerPHP\Traits\ConsoleOutputTrait; @@ -38,6 +39,7 @@ public function __construct( protected readonly InventoryService $inventory, protected readonly ServerRepository $servers, protected readonly SSHService $ssh, + protected readonly PrompterService $prompter, ) { parent::__construct(); } diff --git a/app/Services/PrompterService.php b/app/Services/PrompterService.php new file mode 100644 index 00000000..9f5e4b7b --- /dev/null +++ b/app/Services/PrompterService.php @@ -0,0 +1,246 @@ +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 index c8374c8c..3fadb4f5 100644 --- a/app/Traits/ConsoleInputTrait.php +++ b/app/Traits/ConsoleInputTrait.php @@ -6,20 +6,12 @@ use Closure; -use function Laravel\Prompts\confirm; -use function Laravel\Prompts\multiselect; -use function Laravel\Prompts\password; -use function Laravel\Prompts\pause; -use function Laravel\Prompts\search; -use function Laravel\Prompts\select; -use function Laravel\Prompts\spin; -use function Laravel\Prompts\suggest; -use function Laravel\Prompts\text; - /** * Console input gathering helpers. * - * Requires the using class to have a `protected InputInterface $input` property. + * Requires the using class to have: + * - `protected InputInterface $input` property + * - `protected PrompterService $prompter` property */ trait ConsoleInputTrait { @@ -61,13 +53,14 @@ protected function getOptionOrPrompt( ): mixed { $value = $this->input->getOption($optionName); - // Handle boolean flags (for VALUE_NONE options) - if (is_bool($value) && $value === true) { - return true; + // Handle boolean flags (for VALUE_NONE options) - both true and false are valid + if (is_bool($value)) { + return $value; } - // Handle string options with non-empty values - if (is_string($value) && $value !== '') { + // 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; } @@ -79,18 +72,6 @@ protected function getOptionOrPrompt( // Laravel Prompts Wrappers // ------------------------------------------------------------------------------- - /** - * Remove the annoying newline that Laravel Prompts adds before each prompt. - * - * Uses ANSI escape sequence to move cursor up one line and clear it. - */ - private function suppressPromptSpacing(): void - { - // Move cursor up one line and clear it - // This compensates for the newline Laravel Prompts adds - echo "\033[1A\033[2K"; - } - /** * Prompt for text input. * @@ -111,9 +92,7 @@ protected function promptText( mixed $validate = null, string $hint = '' ): string { - $this->suppressPromptSpacing(); - - return text( + return $this->prompter->text( label: $label, placeholder: $placeholder, default: $default, @@ -141,9 +120,7 @@ protected function promptPassword( mixed $validate = null, string $hint = '' ): string { - $this->suppressPromptSpacing(); - - return password( + return $this->prompter->password( label: $label, placeholder: $placeholder, required: $required, @@ -170,9 +147,7 @@ protected function promptConfirm( string $no = 'No', string $hint = '' ): bool { - $this->suppressPromptSpacing(); - - return confirm( + return $this->prompter->confirm( label: $label, default: $default, yes: $yes, @@ -190,9 +165,7 @@ protected function promptConfirm( */ protected function promptPause(string $message = 'Press enter to continue...'): bool { - $this->suppressPromptSpacing(); - - return pause($message); + return $this->prompter->pause($message); } /** @@ -215,9 +188,7 @@ protected function promptSelect( mixed $validate = null, string $hint = '' ): int|string { - $this->suppressPromptSpacing(); - - return select( + return $this->prompter->select( label: $label, options: $options, default: $default, @@ -249,9 +220,7 @@ protected function promptMultiselect( mixed $validate = null, string $hint = '' ): array { - $this->suppressPromptSpacing(); - - return multiselect( + return $this->prompter->multiselect( label: $label, options: $options, default: $default, @@ -286,9 +255,7 @@ protected function promptSuggest( mixed $validate = null, string $hint = '' ): string { - $this->suppressPromptSpacing(); - - return suggest( + return $this->prompter->suggest( label: $label, options: $options, placeholder: $placeholder, @@ -320,9 +287,7 @@ protected function promptSearch( mixed $validate = null, string $hint = '' ): int|string { - $this->suppressPromptSpacing(); - - return search( + return $this->prompter->search( label: $label, options: $options, placeholder: $placeholder, @@ -346,7 +311,7 @@ protected function promptSpin( Closure $callback, string $message = 'Loading...' ): mixed { - return spin( + return $this->prompter->spin( callback: $callback, message: $message ); diff --git a/tests/Fixtures/MockFilesystem.php b/tests/Fixtures/MockFilesystem.php new file mode 100644 index 00000000..a8570e6d --- /dev/null +++ b/tests/Fixtures/MockFilesystem.php @@ -0,0 +1,137 @@ +exists('config.yml'); // true + * $fs->readFile('config.yml'); // 'test data' + */ +class MockFilesystem extends Filesystem +{ + /** @var array */ + private array $files = []; + + /** @var array */ + private array $directories = []; + + public function __construct( + private readonly bool $initialExists, + private readonly string $initialContent, + private readonly bool $throwOnRead, + private readonly bool $throwOnMkdir, + private readonly bool $throwOnDump, + private readonly string $initialPath + ) { + if ($this->initialExists) { + $this->files[$this->initialPath] = $this->initialContent; + } + + // Extract parent directory from initial path if exists + $directory = dirname($this->initialPath); + $hasDirectory = $directory !== '.' && $directory !== ''; + + $this->directories = $this->throwOnMkdir ? [] : ($hasDirectory ? [$directory] : []); + } + + /** + * @param iterable|string $files + */ + public function exists(string|iterable $files): bool + { + if (is_iterable($files)) { + foreach ($files as $file) { + if (!$this->exists($file)) { + return false; + } + } + return true; + } + + // Check files (direct match or path ends with stored key) + if (isset($this->files[$files])) { + return true; + } + + foreach (array_keys($this->files) as $storedPath) { + if (str_ends_with($files, $storedPath)) { + return true; + } + } + + // Check directories (match exact path only) + foreach ($this->directories as $dir) { + if (rtrim($files, '/\\') === rtrim($dir, '/\\')) { + return true; + } + } + + return false; + } + + public function readFile(string $filename): string + { + if ($this->throwOnRead) { + throw new IOException('Permission denied', 0, null, $filename); + } + + // Try direct match first + if (array_key_exists($filename, $this->files)) { + return $this->files[$filename]; + } + + // Try path ending match + foreach ($this->files as $storedPath => $content) { + if (str_ends_with($filename, $storedPath)) { + return $content; + } + } + + throw new IOException("File does not exist: {$filename}", 0, null, $filename); + } + + /** + * @param iterable|string $dirs + */ + public function mkdir(string|iterable $dirs, int $mode = 0777): void + { + if (is_string($dirs)) { + if ($this->throwOnMkdir) { + throw new IOException('Permission denied', 0, null, $dirs); + } + $this->directories[] = $dirs; + } else { + // Handle iterable of directories + foreach ($dirs as $dir) { + $this->mkdir($dir, $mode); + } + } + } + + public function dumpFile(string $filename, mixed $content): void + { + if ($this->throwOnDump) { + throw new IOException('Write failed', 0, null, $filename); + } + + $this->files[$filename] = (string) $content; + } +} diff --git a/tests/Fixtures/MockPrompter.php b/tests/Fixtures/MockPrompter.php new file mode 100644 index 00000000..9716de62 --- /dev/null +++ b/tests/Fixtures/MockPrompter.php @@ -0,0 +1,202 @@ + $textQueue + * @param array $passwordQueue + * @param array $confirmQueue + * @param array $selectQueue + * @param array> $multiselectQueue + * @param array $suggestQueue + * @param array $searchQueue + * @param array $pauseQueue + */ + public function __construct(private array $textQueue = [], private array $passwordQueue = [], private array $confirmQueue = [], private array $selectQueue = [], private array $multiselectQueue = [], private array $suggestQueue = [], private array $searchQueue = [], private array $pauseQueue = []) + { + } + + // + // Prompt Methods + // ------------------------------------------------------------------------------- + + /** + * Return next text value from queue. + */ + public function text( + string $label, + string $placeholder = '', + string $default = '', + bool $required = true, + mixed $validate = null, + string $hint = '' + ): string { + if (empty($this->textQueue)) { + throw new \RuntimeException('MockPrompter: No text values left in queue for prompt: ' . $label); + } + + return array_shift($this->textQueue); + } + + /** + * Return next password value from queue. + */ + public function password( + string $label, + string $placeholder = '', + bool $required = true, + mixed $validate = null, + string $hint = '' + ): string { + if (empty($this->passwordQueue)) { + throw new \RuntimeException('MockPrompter: No password values left in queue for prompt: ' . $label); + } + + return array_shift($this->passwordQueue); + } + + /** + * Return next confirm value from queue. + */ + public function confirm( + string $label, + bool $default = true, + string $yes = 'Yes', + string $no = 'No', + string $hint = '' + ): bool { + if (empty($this->confirmQueue)) { + throw new \RuntimeException('MockPrompter: No confirm values left in queue for prompt: ' . $label); + } + + return array_shift($this->confirmQueue); + } + + /** + * Return next pause value from queue. + */ + public function pause(string $message = 'Press enter to continue...'): bool + { + if (empty($this->pauseQueue)) { + throw new \RuntimeException('MockPrompter: No pause values left in queue'); + } + + return array_shift($this->pauseQueue); + } + + /** + * Return next select value from queue. + * + * @param array $options + */ + public function select( + string $label, + array $options, + int|string|null $default = null, + int $scroll = 5, + mixed $validate = null, + string $hint = '' + ): int|string { + if (empty($this->selectQueue)) { + throw new \RuntimeException('MockPrompter: No select values left in queue for prompt: ' . $label); + } + + return array_shift($this->selectQueue); + } + + /** + * Return next multiselect value from queue. + * + * @param array $options + * @param array $default + * + * @return array + */ + public function multiselect( + string $label, + array $options, + array $default = [], + int $scroll = 5, + bool $required = false, + mixed $validate = null, + string $hint = '' + ): array { + if (empty($this->multiselectQueue)) { + throw new \RuntimeException('MockPrompter: No multiselect values left in queue for prompt: ' . $label); + } + + return array_shift($this->multiselectQueue); + } + + /** + * Return next suggest value from queue. + * + * @param array|Closure $options + */ + public function suggest( + string $label, + array|Closure $options, + string $placeholder = '', + string $default = '', + int $scroll = 5, + bool $required = true, + mixed $validate = null, + string $hint = '' + ): string { + if (empty($this->suggestQueue)) { + throw new \RuntimeException('MockPrompter: No suggest values left in queue for prompt: ' . $label); + } + + return array_shift($this->suggestQueue); + } + + /** + * Return next search value from queue. + */ + public function search( + string $label, + Closure $options, + string $placeholder = '', + int $scroll = 5, + mixed $validate = null, + string $hint = '' + ): int|string { + if (empty($this->searchQueue)) { + throw new \RuntimeException('MockPrompter: No search values left in queue for prompt: ' . $label); + } + + return array_shift($this->searchQueue); + } + + /** + * Execute callback and return result (no spinner shown in tests). + * + * @template T + * + * @param Closure(): T $callback + * + * @return T + */ + public function spin( + Closure $callback, + string $message = 'Loading...' + ): mixed { + // In tests, just execute the callback without the spinner + return $callback(); + } +} diff --git a/tests/Fixtures/MockSSHService.php b/tests/Fixtures/MockSSHService.php new file mode 100644 index 00000000..61b7b411 --- /dev/null +++ b/tests/Fixtures/MockSSHService.php @@ -0,0 +1,87 @@ +assertCanConnect('host', 22, 'user'); // No exception + * + * @example + * // Simulate connection failure + * $ssh = new MockSSHService(canConnect: false); + * $ssh->assertCanConnect('host', 22, 'user'); // Throws RuntimeException + */ +class MockSSHService extends SSHService +{ + public function __construct(private readonly bool $canConnect) + { + // Skip parent constructor to avoid dependency injection + } + + public function assertCanConnect( + string $host, + int $port, + string $username, + ?string $privateKeyPath = null + ): void { + if (!$this->canConnect) { + throw new \RuntimeException('Failed to connect to SSH server'); + } + // Success - no exception thrown + } + + public function executeCommand( + string $host, + int $port, + string $username, + string $command, + ?string $privateKeyPath = null + ): array { + return ['output' => 'command output', 'exit_code' => 0]; + } + + public function executeScript( + string $host, + int $port, + string $username, + string $scriptPath, + ?string $privateKeyPath = null + ): array { + return ['output' => 'script output', 'exit_code' => 0]; + } + + public function uploadFile( + string $host, + int $port, + string $username, + string $localPath, + string $remotePath, + ?string $privateKeyPath = null + ): void { + // Mock implementation - no actual upload + } + + public function downloadFile( + string $host, + int $port, + string $username, + string $remotePath, + string $localPath, + ?string $privateKeyPath = null + ): void { + // Mock implementation - no actual download + } +} diff --git a/tests/Fixtures/TestConsoleCommand.php b/tests/Fixtures/TestConsoleCommand.php index dc8d441c..8f2db2bd 100644 --- a/tests/Fixtures/TestConsoleCommand.php +++ b/tests/Fixtures/TestConsoleCommand.php @@ -9,6 +9,7 @@ use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use Bigpixelrocket\DeployerPHP\Services\PrompterService; use Bigpixelrocket\DeployerPHP\Services\SSHService; use Bigpixelrocket\DeployerPHP\Traits\ServerHelpersTrait; use Symfony\Component\Console\Command\Command; @@ -34,8 +35,9 @@ public function __construct( InventoryService $inventory, ServerRepository $servers, SSHService $ssh, + PrompterService $prompter, ) { - parent::__construct($container, $env, $inventory, $servers, $ssh); + parent::__construct($container, $env, $inventory, $servers, $ssh, $prompter); } /** diff --git a/tests/Integration/Console/Server/ServerAddCommandTest.php b/tests/Integration/Console/Server/ServerAddCommandTest.php index 5e2dad00..02c5eda7 100644 --- a/tests/Integration/Console/Server/ServerAddCommandTest.php +++ b/tests/Integration/Console/Server/ServerAddCommandTest.php @@ -4,6 +4,7 @@ use Bigpixelrocket\DeployerPHP\Console\Server\ServerAddCommand; use Bigpixelrocket\DeployerPHP\Container; +use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; use Bigpixelrocket\DeployerPHP\Services\SSHService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; @@ -21,12 +22,13 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $inventory = mockInventoryService(true, ['servers' => []]); $inventory->loadInventoryFile(); - $repository = new \Bigpixelrocket\DeployerPHP\Repositories\ServerRepository(); + $repository = new ServerRepository(); $repository->loadInventory($inventory); $ssh = $sshService ?? mockSSHService(); + $prompter = mockPrompter(); - $command = new ServerAddCommand($container, $env, $inventory, $repository, $ssh); + $command = new ServerAddCommand($container, $env, $inventory, $repository, $ssh, $prompter); return new CommandTester($command); } @@ -44,7 +46,7 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $sshService = mockSSHServiceWithBehavior(true); $tester = createServerAddCommandTester($sshService); - // ACT - Capture all output including ANSI sequences from Laravel Prompts + // ACT - Provide all options for fully non-interactive execution ob_start(); $exitCode = $tester->execute([ '--name' => 'production-web', @@ -52,6 +54,7 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe '--port' => '2222', '--username' => 'deployer', '--private-key-path' => '~/.ssh/prod_key', + '--skip' => true, '--yes' => true, ]); ob_end_clean(); @@ -72,11 +75,15 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $sshService = mockSSHServiceWithBehavior(true); $tester = createServerAddCommandTester($sshService); - // ACT - Capture all output including ANSI sequences from Laravel Prompts + // ACT - Provide all required options to avoid prompting ob_start(); $exitCode = $tester->execute([ '--name' => 'web1', '--host' => '192.168.1.1', + '--port' => '22', + '--username' => 'root', + '--private-key-path' => '', + '--skip' => true, '--yes' => true, ]); ob_end_clean(); @@ -96,11 +103,14 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $sshService = mockSSHServiceWithBehavior(true); $tester = createServerAddCommandTester($sshService); - // ACT - Capture all output including ANSI sequences from Laravel Prompts + // ACT - Provide all required options ob_start(); $exitCode = $tester->execute([ '--name' => 'test-server', '--host' => '10.0.0.1', + '--port' => '22', + '--username' => 'root', + '--private-key-path' => '', '--skip' => false, '--yes' => true, ]); @@ -119,11 +129,14 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $sshService = mockSSHServiceWithBehavior(false); $tester = createServerAddCommandTester($sshService); - // ACT - Capture all output including ANSI sequences from Laravel Prompts + // ACT - Provide all required options ob_start(); $exitCode = $tester->execute([ '--name' => 'untested-server', '--host' => '192.168.1.50', + '--port' => '22', + '--username' => 'root', + '--private-key-path' => '', '--skip' => true, '--yes' => true, ]); @@ -146,10 +159,14 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $sshService = mockSSHServiceWithBehavior(true); $tester = createServerAddCommandTester($sshService); - // ACT & ASSERT + // ACT & ASSERT - Provide all required options expect(fn () => $tester->execute([ '--name' => 'test', '--host' => $invalidHost, + '--port' => '22', + '--username' => 'root', + '--private-key-path' => '', + '--skip' => true, '--yes' => true, ]))->toThrow(\InvalidArgumentException::class, 'Invalid host'); })->with([ @@ -163,11 +180,14 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $sshService = mockSSHServiceWithBehavior(true); $tester = createServerAddCommandTester($sshService); - // ACT & ASSERT + // ACT & ASSERT - Provide all required options expect(fn () => $tester->execute([ '--name' => 'test', '--host' => '192.168.1.1', '--port' => $invalidPort, + '--username' => 'root', + '--private-key-path' => '', + '--skip' => true, '--yes' => true, ]))->toThrow(\InvalidArgumentException::class, 'between 1 and 65535'); })->with([ @@ -187,6 +207,10 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $tester->execute([ '--name' => 'duplicate-name', '--host' => '192.168.1.1', + '--port' => '22', + '--username' => 'root', + '--private-key-path' => '', + '--skip' => true, '--yes' => true, ]); ob_end_clean(); @@ -196,6 +220,10 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $exitCode = $tester->execute([ '--name' => 'duplicate-name', '--host' => '192.168.1.2', + '--port' => '22', + '--username' => 'root', + '--private-key-path' => '', + '--skip' => true, '--yes' => true, ]); ob_end_clean(); @@ -213,11 +241,14 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $sshService = mockSSHServiceWithBehavior(false); $tester = createServerAddCommandTester($sshService); - // ACT - Capture all output including ANSI sequences from Laravel Prompts + // ACT - Provide all required options ob_start(); $exitCode = $tester->execute([ '--name' => 'unreachable', '--host' => '192.168.1.99', + '--port' => '22', + '--username' => 'root', + '--private-key-path' => '', '--skip' => false, '--yes' => true, ]); @@ -239,11 +270,14 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $sshService = mockSSHServiceWithBehavior(true); $tester = createServerAddCommandTester($sshService); - // ACT - Capture all output including ANSI sequences from Laravel Prompts + // ACT - Provide all required options ob_start(); $exitCode = $tester->execute([ '--name' => 'confirmed-server', '--host' => '192.168.1.1', + '--port' => '22', + '--username' => 'root', + '--private-key-path' => '', '--skip' => true, '--yes' => true, ]); @@ -268,13 +302,15 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $inventory = mockInventoryService(true, ['servers' => []]); $inventory->loadInventoryFile(); - $repository = new \Bigpixelrocket\DeployerPHP\Repositories\ServerRepository(); + $repository = new ServerRepository(); $repository->loadInventory($inventory); - $command = new ServerAddCommand($container, $env, $inventory, $repository, $sshService); + $prompter = mockPrompter(); + + $command = new ServerAddCommand($container, $env, $inventory, $repository, $sshService, $prompter); $tester = new CommandTester($command); - // ACT - Capture all output including ANSI sequences from Laravel Prompts + // ACT - Provide all required options ob_start(); $tester->execute([ '--name' => 'persisted-server', @@ -282,6 +318,7 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe '--port' => '8022', '--username' => 'admin', '--private-key-path' => '~/.ssh/admin_key', + '--skip' => true, '--yes' => true, ]); ob_end_clean(); @@ -302,7 +339,8 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $sshService = mockSSHServiceWithBehavior(true); $tester = createServerAddCommandTester($sshService); - // ACT + // ACT - Provide all required options + ob_start(); $tester->execute([ '--name' => 'display-test', '--host' => 'example.com', @@ -312,6 +350,7 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe '--skip' => true, '--yes' => true, ]); + ob_end_clean(); // ASSERT $output = $tester->getDisplay(); @@ -332,11 +371,14 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe $sshService = mockSSHServiceWithBehavior(true); $tester = createServerAddCommandTester($sshService); - // ACT - Capture all output including ANSI sequences from Laravel Prompts + // ACT - Provide all required options except private-key-path to test default ob_start(); $tester->execute([ '--name' => 'default-key', '--host' => '192.168.1.1', + '--port' => '22', + '--username' => 'root', + '--private-key-path' => '', '--skip' => true, '--yes' => true, ]); diff --git a/tests/Integration/Console/Server/ServerDeleteCommandTest.php b/tests/Integration/Console/Server/ServerDeleteCommandTest.php index bf5b6804..50cfe221 100644 --- a/tests/Integration/Console/Server/ServerDeleteCommandTest.php +++ b/tests/Integration/Console/Server/ServerDeleteCommandTest.php @@ -5,6 +5,7 @@ use Bigpixelrocket\DeployerPHP\Console\Server\ServerDeleteCommand; use Bigpixelrocket\DeployerPHP\Container; use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; +use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; @@ -34,12 +35,13 @@ function createServerDeleteCommandTester(array $existingServers = []): CommandTe $inventory = mockInventoryService(true, $inventoryData); $inventory->loadInventoryFile(); - $repository = new \Bigpixelrocket\DeployerPHP\Repositories\ServerRepository(); + $repository = new ServerRepository(); $repository->loadInventory($inventory); $ssh = mockSSHService(); + $prompter = mockPrompter(); - $command = new ServerDeleteCommand($container, $env, $inventory, $repository, $ssh); + $command = new ServerDeleteCommand($container, $env, $inventory, $repository, $ssh, $prompter); return new CommandTester($command); } diff --git a/tests/Integration/Console/Server/ServerListCommandTest.php b/tests/Integration/Console/Server/ServerListCommandTest.php index 12ea1dad..e5c926aa 100644 --- a/tests/Integration/Console/Server/ServerListCommandTest.php +++ b/tests/Integration/Console/Server/ServerListCommandTest.php @@ -2,8 +2,10 @@ declare(strict_types=1); +use Bigpixelrocket\DeployerPHP\Console\Server\ServerListCommand; use Bigpixelrocket\DeployerPHP\Container; use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO; +use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; @@ -33,12 +35,13 @@ function createServerListCommandTester(array $existingServers = []): CommandTest $inventory = mockInventoryService(true, $inventoryData); $inventory->loadInventoryFile(); - $repository = new \Bigpixelrocket\DeployerPHP\Repositories\ServerRepository(); + $repository = new ServerRepository(); $repository->loadInventory($inventory); $ssh = mockSSHService(); + $prompter = mockPrompter(); - $command = new \Bigpixelrocket\DeployerPHP\Console\Server\ServerListCommand($container, $env, $inventory, $repository, $ssh); + $command = new ServerListCommand($container, $env, $inventory, $repository, $ssh, $prompter); return new CommandTester($command); } diff --git a/tests/TestHelpers.php b/tests/TestHelpers.php index fc9a8a50..b3b3631b 100644 --- a/tests/TestHelpers.php +++ b/tests/TestHelpers.php @@ -10,15 +10,25 @@ use Bigpixelrocket\DeployerPHP\Services\ProcessFactory; 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 Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand; use Symfony\Component\Dotenv\Dotenv; -use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Yaml\Yaml; +// +// Environment & Configuration Utilities +// ------------------------------------------------------------------------------- + if (!function_exists('setEnv')) { /** * Set or unset environment variables for testing. + * + * @example + * setEnv('API_KEY', 'secret'); // Sets environment variable + * setEnv('API_KEY', null); // Unsets environment variable */ function setEnv(string $key, ?string $value): void { @@ -33,9 +43,22 @@ function setEnv(string $key, ?string $value): void } } +// +// Core Infrastructure Mocks +// ------------------------------------------------------------------------------- + if (!function_exists('mockFilesystem')) { /** * Create a mock filesystem for testing with error simulation and in-memory storage. + * + * @example + * // Basic usage with file content + * $fs = mockFilesystem(exists: true, content: 'data', initialPath: 'config.yml'); + * + * @example + * // Simulate permission errors + * $fs = mockFilesystem(exists: true, throwOnRead: true); + * $fs->readFile('file'); // Throws IOException */ function mockFilesystem( bool $exists = true, @@ -43,103 +66,55 @@ function mockFilesystem( bool $throwOnRead = false, bool $throwOnMkdir = false, bool $throwOnDump = false, - string $initialPath = '.deployer/inventory.yml' + string $initialPath = 'inventory.yml' ): Filesystem { - return new class ($exists, $content, $throwOnRead, $throwOnMkdir, $throwOnDump, $initialPath) extends Filesystem { - private array $files = []; - private array $directories = []; - - public function __construct( - private readonly bool $initialExists, - private readonly string $initialContent, - private readonly bool $throwOnRead, - private readonly bool $throwOnMkdir, - private readonly bool $throwOnDump, - private readonly string $initialPath - ) { - if ($this->initialExists) { - $this->files[$this->initialPath] = $this->initialContent; - } - $this->directories = $this->throwOnMkdir ? [] : ['.deployer']; - } - - public function exists(string|iterable $files): bool - { - if (is_iterable($files)) { - foreach ($files as $file) { - if (!$this->exists($file)) { - return false; - } - } - return true; - } - - // Check files (direct match or path ends with stored key) - if (isset($this->files[$files])) { - return true; - } - - foreach (array_keys($this->files) as $storedPath) { - if (str_ends_with($files, (string) $storedPath)) { - return true; - } - } - - // Check directories (match exact path only) - foreach ($this->directories as $dir) { - if (rtrim($files, '/\\') === rtrim((string) $dir, '/\\')) { - return true; - } - } - - return false; - } - - public function readFile(string $filename): string - { - if ($this->throwOnRead) { - throw new IOException('Permission denied', 0, null, $filename); - } - - // Try direct match first - if (isset($this->files[$filename])) { - return $this->files[$filename]; - } - - // Try path ending match - foreach ($this->files as $storedPath => $content) { - if (str_ends_with($filename, (string) $storedPath)) { - return $content; - } - } - - throw new IOException("File does not exist: {$filename}", 0, null, $filename); - } - - public function mkdir(string|iterable $dirs, int $mode = 0777): void - { - if ($this->throwOnMkdir) { - throw new IOException('Permission denied', 0, null, (string) $dirs); - } - - $this->directories[] = (string) $dirs; - } - - public function dumpFile(string $filename, $content): void - { - if ($this->throwOnDump) { - throw new IOException('Write failed', 0, null, $filename); - } + return new MockFilesystem($exists, $content, $throwOnRead, $throwOnMkdir, $throwOnDump, $initialPath); + } +} - $this->files[$filename] = $content; - } - }; +if (!function_exists('mockFilesystemService')) { + /** + * Create a FilesystemService with a mock Filesystem for testing. + * + * @example + * $service = mockFilesystemService( + * fileExists: true, + * fileContent: 'content', + * filePath: 'data.txt' + * ); + * $service->readFile('data.txt'); // 'content' + */ + function mockFilesystemService( + bool $fileExists = true, + string $fileContent = '', + bool $throwOnRead = false, + bool $throwOnMkdir = false, + bool $throwOnWrite = false, + string $filePath = 'test.txt' + ): FilesystemService { + $mockFs = mockFilesystem($fileExists, $fileContent, $throwOnRead, $throwOnMkdir, $throwOnWrite, $filePath); + return new FilesystemService($mockFs); } } +// +// Service Layer Mocks +// ------------------------------------------------------------------------------- + if (!function_exists('mockEnvService')) { /** * Create a mock EnvService for testing with configurable filesystem behavior. + * + * @example + * // Service with valid .env file + * $service = mockEnvService(fileExists: true, fileContent: 'API_KEY=secret'); + * $service->loadEnvFile(); + * $service->get('API_KEY'); // 'secret' + * + * @example + * // Test missing .env file handling + * $service = mockEnvService(fileExists: false); + * $service->loadEnvFile(); // Handles gracefully */ function mockEnvService( bool $fileExists = true, @@ -155,21 +130,40 @@ function mockEnvService( if (!function_exists('mockInventoryService')) { /** * Create a mock InventoryService for testing with configurable filesystem behavior. + * * Accepts either array data (auto-converts to YAML) or raw string content. + * Use arrays for clean test setup, strings for testing YAML parsing edge cases. + * + * @example + * // Using array data (recommended) + * $service = mockInventoryService( + * fileExists: true, + * data: ['servers' => ['web1' => ['host' => '192.168.1.1']]] + * ); + * + * @example + * // Using raw YAML string + * $service = mockInventoryService( + * fileExists: true, + * data: "servers:\n web1:\n host: 192.168.1.1" + * ); + * + * @example + * // Test write failures + * $service = mockInventoryService(fileExists: true, throwOnWrite: true); */ function mockInventoryService( bool $fileExists = true, - array|string $data = '', + array|string $data = [], bool $throwOnRead = false, bool $throwOnWrite = false ): InventoryService { // Convert array data to YAML - if (is_array($data)) { - $fileContent = empty($data) ? '' : Yaml::dump($data, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); - } else { - $defaultContent = 'widgets:' . PHP_EOL . ' alpha:' . PHP_EOL . ' color: red'; - $fileContent = $data ?: ($fileExists ? $defaultContent : ''); - } + $fileContent = match (true) { + is_array($data) && !empty($data) => Yaml::dump($data, 2, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE), + is_array($data) => '', + default => $data, + }; $mockFs = mockFilesystem($fileExists, $fileContent, $throwOnRead, false, $throwOnWrite, 'inventory.yml'); $filesystemService = new FilesystemService($mockFs); @@ -177,29 +171,16 @@ function mockInventoryService( } } -if (!function_exists('mockFilesystemService')) { - /** - * Create a FilesystemService with a mock Filesystem for testing. - */ - function mockFilesystemService( - bool $fileExists = true, - string $fileContent = '', - bool $throwOnRead = false, - bool $throwOnMkdir = false, - bool $throwOnWrite = false, - string $filePath = 'test.txt' - ): FilesystemService { - $mockFs = mockFilesystem($fileExists, $fileContent, $throwOnRead, $throwOnMkdir, $throwOnWrite, $filePath); - return new FilesystemService($mockFs); - } -} - if (!function_exists('mockProcessFactory')) { /** * Create a ProcessFactory for testing. * * Uses real Filesystem since directory validation requires is_dir() checks. * Tests should use real directories (e.g., __DIR__, sys_get_temp_dir()). + * + * @example + * $factory = mockProcessFactory(); + * $process = $factory->create(['echo', 'test'], __DIR__); */ function mockProcessFactory(): ProcessFactory { @@ -208,11 +189,102 @@ function mockProcessFactory(): ProcessFactory } } +if (!function_exists('mockSSHService')) { + /** + * Create an SSHService for testing with mocked dependencies. + * + * Returns a real SSHService instance with mocked filesystem and env dependencies. + * Use mockSSHServiceWithBehavior() for simulating connection success/failure. + * + * @example + * $ssh = mockSSHService(); + * // Use for testing code that depends on SSHService without network calls + */ + function mockSSHService(): SSHService + { + $envService = mockEnvService(fileExists: true); + $filesystemService = new FilesystemService(new Filesystem()); + + return new SSHService($envService, $filesystemService); + } +} + +if (!function_exists('mockSSHServiceWithBehavior')) { + /** + * Create a mock SSHService that simulates connection success/failure. + * + * @param bool $canConnect Whether SSH connection should succeed (true) or fail (false) + * + * @example + * // Simulate successful connection + * $ssh = mockSSHServiceWithBehavior(canConnect: true); + * $ssh->assertCanConnect('host', 22, 'user'); // No exception + * + * @example + * // Simulate connection failure + * $ssh = mockSSHServiceWithBehavior(canConnect: false); + * $ssh->assertCanConnect('host', 22, 'user'); // Throws RuntimeException + */ + function mockSSHServiceWithBehavior(bool $canConnect = true): SSHService + { + return new MockSSHService($canConnect); + } +} + +if (!function_exists('mockPrompter')) { + /** + * Create a mock PrompterService 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']); + * + * @example + * // Mock confirmations + * $prompter = mockPrompter(confirm: [true, false]); + */ + 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); + } +} + if (!function_exists('mockVersionService')) { /** * Create a VersionService for testing with configurable package name and fallback. * * Uses real Filesystem and ProcessFactory since git operations require real directory checks. + * + * @example + * // Default configuration + * $service = mockVersionService(); + * + * @example + * // Custom package and fallback + * $service = mockVersionService( + * packageName: 'vendor/package', + * fallback: '1.0.0-dev' + * ); */ function mockVersionService( ?string $packageName = null, @@ -221,26 +293,41 @@ function mockVersionService( $filesystemService = new FilesystemService(new Filesystem()); $processFactory = new ProcessFactory($filesystemService); - // Conditionally pass parameters to use VersionService defaults - if ($packageName !== null && $fallback !== null) { - return new VersionService($processFactory, $filesystemService, $packageName, $fallback); - } - - if ($packageName !== null) { - return new VersionService($processFactory, $filesystemService, $packageName); - } - - return new VersionService($processFactory, $filesystemService); + return match (true) { + $packageName !== null && $fallback !== null => new VersionService($processFactory, $filesystemService, $packageName, $fallback), + $packageName !== null => new VersionService($processFactory, $filesystemService, $packageName), + default => new VersionService($processFactory, $filesystemService), + }; } } +// +// Repository Layer Mocks +// ------------------------------------------------------------------------------- + if (!function_exists('mockServerRepository')) { /** * Create a ServerRepository for testing with a loaded inventory service. + * + * Repository is returned fully initialized with inventory loaded and ready for use. + * + * @example + * // Empty repository + * $repo = mockServerRepository(fileExists: true, data: ['servers' => []]); + * + * @example + * // Pre-populated with servers + * $repo = mockServerRepository( + * fileExists: true, + * data: ['servers' => [ + * 'web1' => ['host' => '192.168.1.1', 'port' => 22] + * ]] + * ); + * $repo->findByName('web1'); // Returns ServerDTO */ function mockServerRepository( bool $fileExists = true, - array|string $data = '', + array|string $data = [], bool $throwOnRead = false, bool $throwOnWrite = false ): ServerRepository { @@ -254,106 +341,43 @@ function mockServerRepository( } } -if (!function_exists('mockSSHService')) { - /** - * Create an SSHService for testing with mocked dependencies. - */ - function mockSSHService(): SSHService - { - $envService = mockEnvService(true); - $filesystemService = new FilesystemService(new Filesystem()); - - return new SSHService($envService, $filesystemService); - } -} - -if (!function_exists('mockSSHServiceWithBehavior')) { - /** - * Create a mock SSHService that simulates connection success/failure. - * - * @param bool $canConnect Whether SSH connection should succeed or fail - */ - function mockSSHServiceWithBehavior(bool $canConnect = true): SSHService - { - return new class ($canConnect) extends SSHService { - public function __construct(private readonly bool $canConnect) - { - // Skip parent constructor to avoid dependency injection - } - - public function assertCanConnect( - string $host, - int $port, - string $username, - ?string $privateKeyPath = null - ): void { - if (!$this->canConnect) { - throw new \RuntimeException('Failed to connect to SSH server'); - } - // Success - no exception thrown - } - - public function executeCommand( - string $host, - int $port, - string $username, - string $command, - ?string $privateKeyPath = null - ): array { - return ['output' => 'command output', 'exit_code' => 0]; - } - - public function executeScript( - string $host, - int $port, - string $username, - string $scriptPath, - ?string $privateKeyPath = null - ): array { - return ['output' => 'script output', 'exit_code' => 0]; - } - - public function uploadFile( - string $host, - int $port, - string $username, - string $localPath, - string $remotePath, - ?string $privateKeyPath = null - ): void { - // Mock implementation - no actual upload - } - - public function downloadFile( - string $host, - int $port, - string $username, - string $remotePath, - string $localPath, - ?string $privateKeyPath = null - ): void { - // Mock implementation - no actual download - } - }; - } -} +// +// Command & Integration Test Mocks +// ------------------------------------------------------------------------------- if (!function_exists('mockTestConsoleCommand')) { /** * Create a TestConsoleCommand for testing with mocked dependencies. + * + * Returns a fully configured command with all dependencies injected. + * Useful for testing BaseCommand, console traits, and server helpers. + * + * @example + * // Default configuration + * $command = mockTestConsoleCommand(); + * + * @example + * // Custom environment and inventory + * $command = mockTestConsoleCommand( + * envFileExists: true, + * envContent: 'API_KEY=secret', + * inventoryFileExists: true, + * inventoryData: ['servers' => []] + * ); */ function mockTestConsoleCommand( bool $envFileExists = true, string $envContent = 'API_KEY=test_value', bool $inventoryFileExists = true, - array|string $inventoryData = '', + array|string $inventoryData = [] ): TestConsoleCommand { $container = new Container(); $env = mockEnvService($envFileExists, $envContent); $inventory = mockInventoryService($inventoryFileExists, $inventoryData); $servers = mockServerRepository($inventoryFileExists, $inventoryData); $ssh = mockSSHService(); + $prompter = mockPrompter(); - return new TestConsoleCommand($container, $env, $inventory, $servers, $ssh); + return new TestConsoleCommand($container, $env, $inventory, $servers, $ssh, $prompter); } } diff --git a/tests/Unit/Contracts/BaseCommandTest.php b/tests/Unit/Contracts/BaseCommandTest.php index 9fada588..a3430455 100644 --- a/tests/Unit/Contracts/BaseCommandTest.php +++ b/tests/Unit/Contracts/BaseCommandTest.php @@ -9,6 +9,7 @@ use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use Bigpixelrocket\DeployerPHP\Services\PrompterService; use Bigpixelrocket\DeployerPHP\Services\SSHService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -29,9 +30,10 @@ public function __construct( InventoryService $inventory, ServerRepository $servers, SSHService $ssh, + PrompterService $prompter, private readonly string $testName = 'test-command', ) { - parent::__construct($container, $env, $inventory, $servers, $ssh); + parent::__construct($container, $env, $inventory, $servers, $ssh, $prompter); } protected function configure(): void @@ -60,9 +62,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $inventory = mockInventoryService(true); $servers = mockServerRepository(); $ssh = mockSSHService(); + $prompter = mockPrompter(); // ACT - $command = new TestableBaseCommand($container, $env, $inventory, $servers, $ssh, 'test'); + $command = new TestableBaseCommand($container, $env, $inventory, $servers, $ssh, $prompter, 'test'); // ASSERT expect($command->getName())->toBe('test') @@ -81,7 +84,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $inventory = mockInventoryService(true); $servers = mockServerRepository(); $ssh = mockSSHService(); - $command = new TestableBaseCommand($container, $env, $inventory, $servers, $ssh); + $prompter = mockPrompter(); + $command = new TestableBaseCommand($container, $env, $inventory, $servers, $ssh, $prompter); $tester = new CommandTester($command); // ACT diff --git a/tests/Unit/Services/PrompterServiceTest.php b/tests/Unit/Services/PrompterServiceTest.php new file mode 100644 index 00000000..082f7520 --- /dev/null +++ b/tests/Unit/Services/PrompterServiceTest.php @@ -0,0 +1,103 @@ +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/TestHelpersTest.php b/tests/Unit/TestHelpersTest.php index 3cb89dcb..86b4f1fe 100644 --- a/tests/Unit/TestHelpersTest.php +++ b/tests/Unit/TestHelpersTest.php @@ -18,13 +18,13 @@ // ACT & ASSERT expect($mockFs->exists($checkPath))->toBe($expected, $description); })->with([ - 'directory exists' => [true, 'content', '.deployer/inventory.yml', '.deployer', true, 'directory should exist'], - 'directory with trailing slash' => [true, 'content', '.deployer/inventory.yml', '.deployer/', true, 'directory with trailing slash should exist'], - 'existing file' => [true, 'content', '.deployer/inventory.yml', '.deployer/inventory.yml', true, 'existing file should exist'], - 'non-existent file in existing dir' => [true, 'content', '.deployer/inventory.yml', '.deployer/missing.yml', false, 'non-existent file should not exist'], - 'directory exists when file does not' => [false, '', '.deployer/inventory.yml', '.deployer', true, 'directory should exist'], - 'non-existent file (no substring match)' => [false, '', '.deployer/inventory.yml', '.deployer/inventory.yml', false, 'non-existent file should not exist even if directory matches'], - 'different path no substring match' => [false, '', '.deployer/inventory.yml', '/path/to/.deployer/config.yml', false, 'non-existent file with directory substring should not exist'], + 'directory exists' => [true, 'content', 'config/app.yml', 'config', true, 'directory should exist'], + 'directory with trailing slash' => [true, 'content', 'config/app.yml', 'config/', true, 'directory with trailing slash should exist'], + 'existing file' => [true, 'content', 'inventory.yml', 'inventory.yml', true, 'existing file should exist'], + 'non-existent file in existing dir' => [true, 'content', 'config/app.yml', 'config/missing.yml', false, 'non-existent file should not exist'], + 'directory exists when file does not' => [false, '', 'config/app.yml', 'config', true, 'directory should exist'], + 'non-existent file (no substring match)' => [false, '', 'inventory.yml', 'inventory.yml', false, 'non-existent file should not exist even if directory matches'], + 'different path no substring match' => [false, '', 'config/app.yml', '/path/to/config/other.yml', false, 'non-existent file with directory substring should not exist'], 'direct path match' => [true, 'test', 'inventory.yml', 'inventory.yml', true, 'direct match should work'], 'path ending match' => [true, 'test', 'inventory.yml', '/path/to/inventory.yml', true, 'path ending match should work'], 'different path ending' => [true, 'test', 'inventory.yml', '/different/inventory.yml', true, 'different path ending should work'], diff --git a/tests/Unit/Traits/ConsoleInputTraitTest.php b/tests/Unit/Traits/ConsoleInputTraitTest.php index 9e850fa3..4263805c 100644 --- a/tests/Unit/Traits/ConsoleInputTraitTest.php +++ b/tests/Unit/Traits/ConsoleInputTraitTest.php @@ -5,7 +5,6 @@ namespace Bigpixelrocket\DeployerPHP\Tests\Unit\Traits; use Symfony\Component\Console\Tester\CommandTester; -use Throwable; require_once __DIR__.'/../../TestHelpers.php'; @@ -34,7 +33,7 @@ expect($output)->toContain('Result: production'); }); - it('executes closure when string option is empty', function () { + it('returns empty string when option is explicitly set to empty', function () { // ARRANGE $this->command->setTestMethod('getOptionOrPromptEmpty'); @@ -42,9 +41,9 @@ $this->tester->execute(['--name' => '']); $output = $this->tester->getDisplay(); - // ASSERT - expect($output)->toContain('Closure executed') - ->and($output)->toContain('Result: from-closure'); + // 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 () { @@ -118,40 +117,9 @@ // Prompt Wrappers // ------------------------------------------------------------------------------- - it('prompt wrappers suppress spacing with ANSI escape sequences', function (string $method) { - // ARRANGE - // Expected ANSI sequence: \033[1A (move up) + \033[2K (clear line) - $expectedAnsi = "\033[1A\033[2K"; - $this->command->setTestMethod($method); - - // ACT - // Capture raw output including ANSI sequences using output buffering - ob_start(); - - try { - // Execute command which calls the prompt wrapper - // It will output ANSI then fail on actual prompt (non-interactive mode) - $this->tester->execute([]); - } catch (Throwable) { - // Expected to fail in non-interactive mode, but ANSI was already output - } - - $output = ob_get_clean(); - - // ASSERT - // Verify the ANSI escape sequence was output for spacing suppression - expect($output)->toContain($expectedAnsi); - })->with([ - 'promptText', - 'promptPassword', - 'promptConfirm', - 'promptPause', - 'promptSelect', - 'promptMultiselect', - 'promptSuggest', - // Note: promptSearch is not tested here as it requires user interaction - // and cannot be tested in non-interactive mode even with default values - ]); + // 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