From 5ce8275d22a632cd5b5eef3bf873fc69e8daf798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sat, 4 Oct 2025 17:55:35 +0300 Subject: [PATCH 1/5] test: extract MockFilesystem and MockSSHService to fixtures Refactor TestHelpers.php to use dedicated mock classes for better maintainability. - Extract inline anonymous classes to separate MockFilesystem and MockSSHService classes in fixtures/ - Add comprehensive docblocks with usage examples - Organize functions into logical sections with comments - Use match expressions for cleaner conditional logic - Improve defaults and type safety throughout --- tests/Fixtures/MockFilesystem.php | 132 ++++++++++ tests/Fixtures/MockSSHService.php | 87 +++++++ tests/TestHelpers.php | 400 ++++++++++++++---------------- 3 files changed, 411 insertions(+), 208 deletions(-) create mode 100644 tests/Fixtures/MockFilesystem.php create mode 100644 tests/Fixtures/MockSSHService.php diff --git a/tests/Fixtures/MockFilesystem.php b/tests/Fixtures/MockFilesystem.php new file mode 100644 index 00000000..1749d441 --- /dev/null +++ b/tests/Fixtures/MockFilesystem.php @@ -0,0 +1,132 @@ +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; + } + $this->directories = $this->throwOnMkdir ? [] : ['.deployer']; + } + + /** + * @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/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/TestHelpers.php b/tests/TestHelpers.php index fc9a8a50..43f72b6f 100644 --- a/tests/TestHelpers.php +++ b/tests/TestHelpers.php @@ -10,15 +10,24 @@ 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\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 +42,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, @@ -45,101 +67,53 @@ function mockFilesystem( bool $throwOnDump = false, string $initialPath = '.deployer/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 +129,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 +170,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 +188,64 @@ 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('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 +254,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,99 +302,35 @@ 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); From efe5a029cf8a887eec535f18069184c21b37af19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 5 Oct 2025 19:24:49 +0300 Subject: [PATCH 2/5] feat(prompter): extract prompter service for better testability Extract Laravel Prompts wrappers from ConsoleInputTrait into dedicated PrompterService. - Add PrompterService with all prompt methods and spacing suppression - Update ConsoleInputTrait to delegate to injected PrompterService - Inject PrompterService into BaseCommand constructor - Add MockPrompter fixture for testing - Update all tests to use non-interactive options and mock prompter - Add unit tests for PrompterService ANSI suppression --- app/Contracts/BaseCommand.php | 2 + app/Services/PrompterService.php | 247 ++++++++++++++++++ app/Traits/ConsoleInputTrait.php | 71 ++--- tests/Fixtures/MockPrompter.php | 202 ++++++++++++++ tests/Fixtures/TestConsoleCommand.php | 4 +- .../Console/Server/ServerAddCommandTest.php | 72 +++-- .../Server/ServerDeleteCommandTest.php | 6 +- .../Console/Server/ServerListCommandTest.php | 7 +- tests/TestHelpers.php | 42 ++- tests/Unit/Contracts/BaseCommandTest.php | 10 +- tests/Unit/Services/PrompterServiceTest.php | 103 ++++++++ tests/Unit/Traits/ConsoleInputTraitTest.php | 46 +--- 12 files changed, 696 insertions(+), 116 deletions(-) create mode 100644 app/Services/PrompterService.php create mode 100644 tests/Fixtures/MockPrompter.php create mode 100644 tests/Unit/Services/PrompterServiceTest.php 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..393c9559 --- /dev/null +++ b/app/Services/PrompterService.php @@ -0,0 +1,247 @@ +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/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/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 43f72b6f..7dab953e 100644 --- a/tests/TestHelpers.php +++ b/tests/TestHelpers.php @@ -11,6 +11,7 @@ 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; @@ -230,6 +231,44 @@ function mockSSHServiceWithBehavior(bool $canConnect = true): SSHService } } +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. @@ -337,7 +376,8 @@ function mockTestConsoleCommand( $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/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 From d36f8811e8dfa34c935e9cd338db77cbe446c742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 5 Oct 2025 19:56:00 +0300 Subject: [PATCH 3/5] test: improve MockFilesystem path matching Update exists() and getContents() to support basename matching alongside path suffix matching. This allows more flexible mocking of files by name without requiring full path specification, improving test usability. --- tests/Fixtures/MockFilesystem.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/Fixtures/MockFilesystem.php b/tests/Fixtures/MockFilesystem.php index 1749d441..c15a980e 100644 --- a/tests/Fixtures/MockFilesystem.php +++ b/tests/Fixtures/MockFilesystem.php @@ -61,13 +61,14 @@ public function exists(string|iterable $files): bool return true; } - // Check files (direct match or path ends with stored key) + // Check files (direct match or basename match) if (isset($this->files[$files])) { return true; } foreach (array_keys($this->files) as $storedPath) { - if (str_ends_with($files, $storedPath)) { + // Match if the basename matches or if it's a path component match + if (basename($files) === $storedPath || str_ends_with($files, '/'.$storedPath)) { return true; } } @@ -93,9 +94,10 @@ public function readFile(string $filename): string return $this->files[$filename]; } - // Try path ending match + // Try basename or path component match foreach ($this->files as $storedPath => $content) { - if (str_ends_with($filename, $storedPath)) { + // Match if the basename matches or if it's a path component match + if (basename($filename) === $storedPath || str_ends_with($filename, '/'.$storedPath)) { return $content; } } From b559c61aa289c95f974e4d36158e953d9bfceb35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 5 Oct 2025 19:56:01 +0300 Subject: [PATCH 4/5] style: remove trailing newline in PrompterService.php --- app/Services/PrompterService.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/Services/PrompterService.php b/app/Services/PrompterService.php index 393c9559..9f5e4b7b 100644 --- a/app/Services/PrompterService.php +++ b/app/Services/PrompterService.php @@ -244,4 +244,3 @@ private function suppressPromptSpacing(): void echo "\033[1A\033[2K"; } } - From 1eca42374d5c6c5f0e6e832f543ddaa745392a42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucian=20V=C4=83c=C4=83roiu?= Date: Sun, 5 Oct 2025 20:57:39 +0300 Subject: [PATCH 5/5] refactor(test): extract directory dynamically from mock path Replace hardcoded '.deployer' directory in MockFilesystem with dynamic extraction from the initial path parameter. Simplifies path matching logic by removing basename checks in favor of consistent path ending matches. Changes: - Extract parent directory from initialPath in MockFilesystem constructor - Simplify exists() and readFile() methods to use consistent path matching - Update mockFilesystem() default path from '.deployer/inventory.yml' to 'inventory.yml' - Update test datasets to reflect new flexible directory handling --- tests/Fixtures/MockFilesystem.php | 17 ++++++++++------- tests/TestHelpers.php | 2 +- tests/Unit/TestHelpersTest.php | 14 +++++++------- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/tests/Fixtures/MockFilesystem.php b/tests/Fixtures/MockFilesystem.php index c15a980e..a8570e6d 100644 --- a/tests/Fixtures/MockFilesystem.php +++ b/tests/Fixtures/MockFilesystem.php @@ -44,7 +44,12 @@ public function __construct( if ($this->initialExists) { $this->files[$this->initialPath] = $this->initialContent; } - $this->directories = $this->throwOnMkdir ? [] : ['.deployer']; + + // Extract parent directory from initial path if exists + $directory = dirname($this->initialPath); + $hasDirectory = $directory !== '.' && $directory !== ''; + + $this->directories = $this->throwOnMkdir ? [] : ($hasDirectory ? [$directory] : []); } /** @@ -61,14 +66,13 @@ public function exists(string|iterable $files): bool return true; } - // Check files (direct match or basename match) + // Check files (direct match or path ends with stored key) if (isset($this->files[$files])) { return true; } foreach (array_keys($this->files) as $storedPath) { - // Match if the basename matches or if it's a path component match - if (basename($files) === $storedPath || str_ends_with($files, '/'.$storedPath)) { + if (str_ends_with($files, $storedPath)) { return true; } } @@ -94,10 +98,9 @@ public function readFile(string $filename): string return $this->files[$filename]; } - // Try basename or path component match + // Try path ending match foreach ($this->files as $storedPath => $content) { - // Match if the basename matches or if it's a path component match - if (basename($filename) === $storedPath || str_ends_with($filename, '/'.$storedPath)) { + if (str_ends_with($filename, $storedPath)) { return $content; } } diff --git a/tests/TestHelpers.php b/tests/TestHelpers.php index 7dab953e..b3b3631b 100644 --- a/tests/TestHelpers.php +++ b/tests/TestHelpers.php @@ -66,7 +66,7 @@ function mockFilesystem( bool $throwOnRead = false, bool $throwOnMkdir = false, bool $throwOnDump = false, - string $initialPath = '.deployer/inventory.yml' + string $initialPath = 'inventory.yml' ): Filesystem { return new MockFilesystem($exists, $content, $throwOnRead, $throwOnMkdir, $throwOnDump, $initialPath); } 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'],