diff --git a/readme.md b/readme.md index b645d8a..d54fec6 100644 --- a/readme.md +++ b/readme.md @@ -474,6 +474,10 @@ public function execute(): int ## Testing Your Commands +The CLI component provides comprehensive testing support, including the ability to test commands that require user input. + +### Testing Commands Without User Input + ```php use PHPUnit\Framework\TestCase; use Neuron\Cli\Console\Input; @@ -484,23 +488,155 @@ class MakeControllerCommandTest extends TestCase public function testExecute(): void { $command = new MakeControllerCommand(); - + // Mock input $input = new Input(['UserController', '--resource']); $output = new Output(false); // No colors for testing - + $command->setInput($input); $command->setOutput($output); $command->configure(); $input->parse($command); - + $exitCode = $command->execute(); - + $this->assertEquals(0, $exitCode); } } ``` +### Testing Commands With User Input + +Commands that use `prompt()`, `confirm()`, `secret()`, or `choice()` can be tested using the `TestInputReader`: + +```php +use PHPUnit\Framework\TestCase; +use Neuron\Cli\Console\Input; +use Neuron\Cli\Console\Output; +use Neuron\Cli\IO\TestInputReader; + +class SetupCommandTest extends TestCase +{ + public function testInteractiveSetup(): void + { + $command = new SetupCommand(); + + // Create test input reader with pre-programmed responses + $inputReader = new TestInputReader(); + $inputReader->addResponse('my-project'); // Project name + $inputReader->addResponse('John Doe'); // Author name + $inputReader->addResponse('john@example.com'); // Email + $inputReader->addResponse('yes'); // Confirmation + + // Configure command + $input = new Input([]); + $output = new Output(false); + + $command->setInput($input); + $command->setOutput($output); + $command->setInputReader($inputReader); + + // Execute command + $exitCode = $command->execute(); + + // Assertions + $this->assertEquals(0, $exitCode); + + // Verify the prompts that were shown + $prompts = $inputReader->getPromptHistory(); + $this->assertCount(4, $prompts); + $this->assertStringContainsString('Project name', $prompts[0]); + $this->assertStringContainsString('Author name', $prompts[1]); + } + + public function testUserCancelsSetup(): void + { + $command = new SetupCommand(); + + // User will cancel the setup + $inputReader = new TestInputReader(); + $inputReader->addResponses([ + 'test-project', + 'Test User', + 'test@example.com', + 'no' // Cancel confirmation + ]); + + $input = new Input([]); + $output = new Output(false); + + $command->setInput($input); + $command->setOutput($output); + $command->setInputReader($inputReader); + + $exitCode = $command->execute(); + + // Should return non-zero exit code when cancelled + $this->assertNotEquals(0, $exitCode); + } +} +``` + +### Using Input Reader in Commands + +To make your commands testable, use the convenience methods provided by the `Command` base class: + +```php +class SetupCommand extends Command +{ + public function execute(): int + { + // Use built-in convenience methods instead of reading STDIN directly + $name = $this->prompt('Enter project name: '); + + if ($this->confirm('Enable caching?', true)) { + // User confirmed + } + + $password = $this->secret('Enter password: '); + + $env = $this->choice( + 'Select environment:', + ['development', 'staging', 'production'], + 'development' + ); + + return 0; + } +} +``` + +These convenience methods automatically use the injected `IInputReader`, making your commands fully testable without requiring actual user input. + +### TestInputReader Features + +The `TestInputReader` class provides: + +- **Response Queue**: Pre-program multiple responses with `addResponse()` or `addResponses()` +- **Prompt History**: Track all prompts shown with `getPromptHistory()` +- **Automatic Validation**: Throws exceptions if responses run out, helping catch test bugs +- **Fluent Interface**: Chain `addResponse()` calls for cleaner test setup +- **Full Interface Support**: Implements all `IInputReader` methods (prompt, confirm, secret, choice) + +```php +$reader = new TestInputReader(); +$reader + ->addResponse('value1') + ->addResponse('value2') + ->addResponse('yes'); + +// Check if there are responses remaining +if ($reader->hasMoreResponses()) { + $count = $reader->getRemainingResponseCount(); +} + +// Get history of prompts +$prompts = $reader->getPromptHistory(); + +// Reset for reuse +$reader->reset(); +``` + ## Contributing When adding new features to the CLI component: diff --git a/src/Cli/Commands/Command.php b/src/Cli/Commands/Command.php index 3f1d5bc..ee854d1 100644 --- a/src/Cli/Commands/Command.php +++ b/src/Cli/Commands/Command.php @@ -4,6 +4,8 @@ use Neuron\Cli\Console\Input; use Neuron\Cli\Console\Output; +use Neuron\Cli\IO\IInputReader; +use Neuron\Cli\IO\StdinInputReader; /** * Abstract base class for all CLI commands. @@ -14,6 +16,7 @@ abstract class Command { protected Input $input; protected Output $output; + protected ?IInputReader $inputReader = null; protected array $arguments = []; protected array $options = []; @@ -287,7 +290,7 @@ public function hasOption( string $name ): bool /** * Validate that required arguments are present - * + * * @throws \InvalidArgumentException * @return void */ @@ -301,4 +304,128 @@ public function validate(): void } } } + + /** + * Get the input reader instance. + * + * Creates a default StdinInputReader if not already set. + * This enables testable CLI commands by abstracting user input. + * + * If output hasn't been set, a default Output instance will be created automatically. + * + * @return IInputReader + */ + protected function getInputReader(): IInputReader + { + if( !$this->inputReader ) { + // Ensure output is initialized before creating StdinInputReader + if( !isset( $this->output ) ) { + $this->output = new Output(); + } + + $this->inputReader = new StdinInputReader( $this->output ); + } + + return $this->inputReader; + } + + /** + * Set the input reader (for dependency injection, especially in tests). + * + * This allows tests to inject a TestInputReader with pre-programmed + * responses instead of requiring actual user input. + * + * @param IInputReader $inputReader + * @return self + */ + public function setInputReader( IInputReader $inputReader ): self + { + $this->inputReader = $inputReader; + return $this; + } + + /** + * Prompt user for input. + * + * Convenience method that delegates to the input reader. + * + * Example: + * ```php + * $name = $this->prompt( "Enter your name: " ); + * ``` + * + * @param string $message The prompt message to display + * @return string The user's response (trimmed) + */ + protected function prompt( string $message ): string + { + return $this->getInputReader()->prompt( $message ); + } + + /** + * Ask user for yes/no confirmation. + * + * Convenience method that delegates to the input reader. + * Accepts: y, yes, true, 1 (case-insensitive) as positive responses. + * + * Example: + * ```php + * if( $this->confirm( "Delete all files?" ) ) { + * // User confirmed + * } + * ``` + * + * @param string $message The confirmation message + * @param bool $default Default value if user just presses enter + * @return bool True if user confirms, false otherwise + */ + protected function confirm( string $message, bool $default = false ): bool + { + return $this->getInputReader()->confirm( $message, $default ); + } + + /** + * Prompt for sensitive input without echoing to console. + * + * Convenience method that delegates to the input reader. + * Note: Secret input hiding only works on Unix-like systems. + * + * Example: + * ```php + * $password = $this->secret( "Enter password: " ); + * ``` + * + * @param string $message The prompt message + * @return string The user's input (trimmed) + */ + protected function secret( string $message ): string + { + return $this->getInputReader()->secret( $message ); + } + + /** + * Prompt user to select from a list of options. + * + * Convenience method that delegates to the input reader. + * Users can select by entering either the option index (numeric) + * or the exact option text. + * + * Example: + * ```php + * $env = $this->choice( + * "Select environment:", + * ['development', 'staging', 'production'], + * 'development' + * ); + * ``` + * + * @param string $message The prompt message + * @param array $options Available options + * @param string|null $default Default option (will be marked with *) + * @return string The selected option + */ + protected function choice( string $message, array $options, ?string $default = null ): string + { + return $this->getInputReader()->choice( $message, $options, $default ); + } } \ No newline at end of file diff --git a/src/Cli/IO/IInputReader.php b/src/Cli/IO/IInputReader.php new file mode 100644 index 0000000..708cbb8 --- /dev/null +++ b/src/Cli/IO/IInputReader.php @@ -0,0 +1,59 @@ + $options Available options + * @param string|null $default Default option (will be marked with *) + * @return string The selected option + */ + public function choice( string $message, array $options, ?string $default = null ): string; +} diff --git a/src/Cli/IO/StdinInputReader.php b/src/Cli/IO/StdinInputReader.php new file mode 100644 index 0000000..ddecd5a --- /dev/null +++ b/src/Cli/IO/StdinInputReader.php @@ -0,0 +1,178 @@ +output->write( $message, false ); + $input = fgets( STDIN ); + return $input !== false ? trim( $input ) : ''; + } + + /** + * @inheritDoc + */ + public function confirm( string $message, bool $default = false ): bool + { + $suffix = $default ? ' [Y/n]: ' : ' [y/N]: '; + $response = $this->prompt( $message . $suffix ); + + // If user just presses enter, use default + if( $response === '' ) { + return $default; + } + + // Accept y, yes, true, 1 as positive (case-insensitive) + return in_array( strtolower( $response ), ['y', 'yes', 'true', '1'], true ); + } + + /** + * @inheritDoc + */ + public function secret( string $message ): string + { + $this->output->write( $message, false ); + + // Check if we can hide input (Unix-like system + TTY) + $canHideInput = strtoupper( substr( PHP_OS, 0, 3 ) ) !== 'WIN' && $this->isTty(); + + if( $canHideInput ) { + // Disable terminal echo with exception safety + try { + system( 'stty -echo' ); + $input = fgets( STDIN ); + } finally { + // Always restore terminal echo, even if an exception occurred + system( 'stty echo' ); + } + + // Add newline since user's enter key wasn't echoed + $this->output->writeln( '' ); + } else { + // Fall back to visible input (Windows or non-TTY) + // On Windows, a proper implementation would use COM or other Windows-specific methods + // For non-TTY (CI/CD, piped input), we just read normally without trying stty + $input = fgets( STDIN ); + } + + return $input !== false ? trim( $input ) : ''; + } + + /** + * Check if STDIN is a TTY (terminal). + * + * This prevents stty errors when running in non-interactive environments + * like CI/CD pipelines, automated scripts, or with piped input. + * + * @return bool True if STDIN is a TTY, false otherwise + */ + private function isTty(): bool + { + // Use posix_isatty if available (preferred, more reliable) + if( function_exists( 'posix_isatty' ) ) { + return @posix_isatty( STDIN ); + } + + // Fall back to stream_isatty (PHP 7.2+) + if( function_exists( 'stream_isatty' ) ) { + return @stream_isatty( STDIN ); + } + + // If neither function is available, assume it's not a TTY to be safe + // This prevents errors in environments where we can't check + return false; + } + + /** + * @inheritDoc + */ + public function choice( string $message, array $options, ?string $default = null ): string + { + $attempts = 0; + + while( $attempts < self::MAX_RETRY_ATTEMPTS ) { + // Display the prompt message (only on first attempt) + if( $attempts === 0 ) { + $this->output->writeln( $message ); + $this->output->writeln( '' ); + + // Display options with index numbers + foreach( $options as $index => $option ) { + $marker = ($default === $option) ? '*' : ' '; + $this->output->writeln( " [{$marker}] {$index}. {$option}" ); + } + + $this->output->writeln( '' ); + } + + // Prompt for selection + $prompt = $default !== null ? "Choice [{$default}]: " : "Choice: "; + $response = $this->prompt( $prompt ); + + // If user just presses enter and there's a default, use it + if( $response === '' && $default !== null ) { + return $default; + } + + // Check if response is a numeric index + if( is_numeric( $response ) ) { + $index = (int)$response; + if( isset( $options[$index] ) ) { + return $options[$index]; + } + } + + // Check if response matches an option exactly + if( in_array( $response, $options, true ) ) { + return $response; + } + + // Invalid choice - increment counter and show error + $attempts++; + + if( $attempts < self::MAX_RETRY_ATTEMPTS ) { + $this->output->error( "Invalid choice. Please try again." ); + $this->output->writeln( '' ); + } + } + + // Max retries exceeded - fall back to default or throw exception + if( $default !== null ) { + $this->output->warning( "Maximum retry attempts exceeded. Using default: {$default}" ); + return $default; + } + + throw new \RuntimeException( + 'Maximum retry attempts exceeded and no default value provided for choice prompt' + ); + } +} diff --git a/src/Cli/IO/TestInputReader.php b/src/Cli/IO/TestInputReader.php new file mode 100644 index 0000000..bdb7102 --- /dev/null +++ b/src/Cli/IO/TestInputReader.php @@ -0,0 +1,198 @@ +addResponse( 'yes' ); + * $reader->addResponse( 'John Doe' ); + * $reader->addResponse( '2' ); // Select option index 2 + * + * $command->setInputReader( $reader ); + * $command->execute(); + * + * // Verify prompts were shown + * $prompts = $reader->getPromptHistory(); + * $this->assertCount( 3, $prompts ); + * ``` + * + * @package Neuron\Cli\IO + */ +class TestInputReader implements IInputReader +{ + /** + * Queue of responses to return. + * + * @var array + */ + private array $responses = []; + + /** + * Current position in the responses queue. + * + * @var int + */ + private int $currentIndex = 0; + + /** + * History of all prompts that were shown. + * + * @var array + */ + private array $promptHistory = []; + + /** + * Add a response to the queue. + * + * Responses are returned in the order they are added. + * + * @param string $response The response to return when next prompted + * @return self For method chaining + */ + public function addResponse( string $response ): self + { + $this->responses[] = $response; + return $this; + } + + /** + * Add multiple responses at once. + * + * @param array $responses Array of responses + * @return self For method chaining + */ + public function addResponses( array $responses ): self + { + $this->responses = array_merge( $this->responses, $responses ); + return $this; + } + + /** + * Get the history of all prompts that were displayed. + * + * Useful for asserting that the correct prompts were shown to the user. + * + * @return array + */ + public function getPromptHistory(): array + { + return $this->promptHistory; + } + + /** + * Check if there are responses remaining in the queue. + * + * @return bool True if more responses are available, false otherwise + */ + public function hasMoreResponses(): bool + { + return isset( $this->responses[$this->currentIndex] ); + } + + /** + * Get the number of responses remaining in the queue. + * + * @return int Number of responses not yet consumed + */ + public function getRemainingResponseCount(): int + { + return count( $this->responses ) - $this->currentIndex; + } + + /** + * Reset the input reader to initial state. + * + * Clears all responses and prompt history. + * + * @return void + */ + public function reset(): void + { + $this->responses = []; + $this->currentIndex = 0; + $this->promptHistory = []; + } + + /** + * @inheritDoc + * + * @throws \RuntimeException If no response is configured for this prompt + */ + public function prompt( string $message ): string + { + $this->promptHistory[] = $message; + + if( !isset( $this->responses[$this->currentIndex] ) ) { + throw new \RuntimeException( + "No response configured for prompt #{$this->currentIndex}: {$message}\n" . + "Available responses: " . count( $this->responses ) . "\n" . + "Use addResponse() to add more responses before executing the command." + ); + } + + return $this->responses[$this->currentIndex++]; + } + + /** + * @inheritDoc + */ + public function confirm( string $message, bool $default = false ): bool + { + $response = $this->prompt( $message ); + + // If response is empty, use default + if( $response === '' ) { + return $default; + } + + // Accept y, yes, true, 1 as positive (case-insensitive) + return in_array( strtolower( $response ), ['y', 'yes', 'true', '1'], true ); + } + + /** + * @inheritDoc + */ + public function secret( string $message ): string + { + // For testing, secrets work the same as regular prompts + // (no need to hide input in tests) + return $this->prompt( $message ); + } + + /** + * @inheritDoc + */ + public function choice( string $message, array $options, ?string $default = null ): string + { + $response = $this->prompt( $message ); + + // If response is empty and there's a default, use it + if( $response === '' && $default !== null ) { + return $default; + } + + // Check if response is a numeric index + if( is_numeric( $response ) ) { + $index = (int)$response; + if( isset( $options[$index] ) ) { + return $options[$index]; + } + } + + // Check if response matches an option exactly + if( in_array( $response, $options, true ) ) { + return $response; + } + + // In tests, we don't re-prompt on invalid choice + // Just return the response as-is and let the test verify behavior + return $response; + } +} diff --git a/tests/Cli/Commands/CommandTest.php b/tests/Cli/Commands/CommandTest.php index 0cd1b99..0ba3e53 100644 --- a/tests/Cli/Commands/CommandTest.php +++ b/tests/Cli/Commands/CommandTest.php @@ -5,6 +5,8 @@ use Neuron\Cli\Commands\Command; use Neuron\Cli\Console\Input; use Neuron\Cli\Console\Output; +use Neuron\Cli\IO\IInputReader; +use Neuron\Cli\IO\TestInputReader; use PHPUnit\Framework\TestCase; class CommandTest extends TestCase @@ -168,6 +170,217 @@ public function testValidateWithAllRequiredArgumentsPresent(): void $this->command->validate(); $this->assertTrue( true ); } + + public function testSetInputReader(): void + { + $inputReader = new TestInputReader(); + $result = $this->command->setInputReader( $inputReader ); + + // Should return self for fluent interface + $this->assertSame( $this->command, $result ); + + // Verify it was set using reflection + $reflection = new \ReflectionClass( $this->command ); + $prop = $reflection->getProperty( 'inputReader' ); + $prop->setAccessible( true ); + + $this->assertSame( $inputReader, $prop->getValue( $this->command ) ); + } + + public function testGetInputReaderCreatesDefaultWhenNotSet(): void + { + $this->command->setInput( new Input( [] ) ); + $this->command->setOutput( new Output( false ) ); + + // Access protected method using reflection + $reflection = new \ReflectionClass( $this->command ); + $method = $reflection->getMethod( 'getInputReader' ); + $method->setAccessible( true ); + + $reader = $method->invoke( $this->command ); + + $this->assertInstanceOf( IInputReader::class, $reader ); + } + + public function testGetInputReaderCreatesDefaultOutputWhenNotSet(): void + { + // Don't set output - should auto-initialize + // Access protected method using reflection + $reflection = new \ReflectionClass( $this->command ); + $method = $reflection->getMethod( 'getInputReader' ); + $method->setAccessible( true ); + + // Should not throw exception even though output wasn't set + $reader = $method->invoke( $this->command ); + + $this->assertInstanceOf( IInputReader::class, $reader ); + + // Verify output was auto-initialized + $outputProp = $reflection->getProperty( 'output' ); + $outputProp->setAccessible( true ); + $output = $outputProp->getValue( $this->command ); + + $this->assertInstanceOf( Output::class, $output ); + } + + public function testGetInputReaderReturnsInjectedReader(): void + { + $inputReader = new TestInputReader(); + $this->command->setInputReader( $inputReader ); + + // Access protected method using reflection + $reflection = new \ReflectionClass( $this->command ); + $method = $reflection->getMethod( 'getInputReader' ); + $method->setAccessible( true ); + + $reader = $method->invoke( $this->command ); + + $this->assertSame( $inputReader, $reader ); + } + + public function testPromptDelegatesToInputReader(): void + { + $inputReader = new TestInputReader(); + $inputReader->addResponse( 'test response' ); + + $command = new InteractiveTestCommand(); + $command->setInput( new Input( [] ) ); + $command->setOutput( new Output( false ) ); + $command->setInputReader( $inputReader ); + + $result = $command->callPrompt( 'Enter something: ' ); + + $this->assertEquals( 'test response', $result ); + + // Verify prompt was shown + $history = $inputReader->getPromptHistory(); + $this->assertCount( 1, $history ); + $this->assertEquals( 'Enter something: ', $history[0] ); + } + + public function testConfirmDelegatesToInputReader(): void + { + $inputReader = new TestInputReader(); + $inputReader->addResponse( 'yes' ); + + $command = new InteractiveTestCommand(); + $command->setInput( new Input( [] ) ); + $command->setOutput( new Output( false ) ); + $command->setInputReader( $inputReader ); + + $result = $command->callConfirm( 'Are you sure?', false ); + + $this->assertTrue( $result ); + } + + public function testConfirmWithDefault(): void + { + $inputReader = new TestInputReader(); + $inputReader->addResponse( '' ); // Empty response + + $command = new InteractiveTestCommand(); + $command->setInput( new Input( [] ) ); + $command->setOutput( new Output( false ) ); + $command->setInputReader( $inputReader ); + + // Should use default when response is empty + $result = $command->callConfirm( 'Continue?', true ); + + $this->assertTrue( $result ); + } + + public function testSecretDelegatesToInputReader(): void + { + $inputReader = new TestInputReader(); + $inputReader->addResponse( 'secret-password' ); + + $command = new InteractiveTestCommand(); + $command->setInput( new Input( [] ) ); + $command->setOutput( new Output( false ) ); + $command->setInputReader( $inputReader ); + + $result = $command->callSecret( 'Enter password: ' ); + + $this->assertEquals( 'secret-password', $result ); + } + + public function testChoiceDelegatesToInputReader(): void + { + $inputReader = new TestInputReader(); + $inputReader->addResponse( 'staging' ); + + $command = new InteractiveTestCommand(); + $command->setInput( new Input( [] ) ); + $command->setOutput( new Output( false ) ); + $command->setInputReader( $inputReader ); + + $options = ['development', 'staging', 'production']; + $result = $command->callChoice( 'Select environment:', $options ); + + $this->assertEquals( 'staging', $result ); + } + + public function testChoiceByIndex(): void + { + $inputReader = new TestInputReader(); + $inputReader->addResponse( '1' ); // Select index 1 + + $command = new InteractiveTestCommand(); + $command->setInput( new Input( [] ) ); + $command->setOutput( new Output( false ) ); + $command->setInputReader( $inputReader ); + + $options = ['dev', 'staging', 'prod']; + $result = $command->callChoice( 'Select:', $options ); + + $this->assertEquals( 'staging', $result ); + } + + public function testChoiceWithDefault(): void + { + $inputReader = new TestInputReader(); + $inputReader->addResponse( '' ); // Empty response + + $command = new InteractiveTestCommand(); + $command->setInput( new Input( [] ) ); + $command->setOutput( new Output( false ) ); + $command->setInputReader( $inputReader ); + + $options = ['dev', 'staging', 'prod']; + $result = $command->callChoice( 'Select:', $options, 'dev' ); + + $this->assertEquals( 'dev', $result ); + } + + public function testPromptWorksWithoutOutputSet(): void + { + // Test that prompt works even if setOutput() was never called + $inputReader = new TestInputReader(); + $inputReader->addResponse( 'test' ); + + $command = new InteractiveTestCommand(); + $command->setInputReader( $inputReader ); + + // Should not throw exception even though output wasn't set + $result = $command->callPrompt( 'Enter: ' ); + + $this->assertEquals( 'test', $result ); + } + + public function testConfirmWorksWithoutOutputSet(): void + { + // Test that confirm works even if setOutput() was never called + $inputReader = new TestInputReader(); + $inputReader->addResponse( 'yes' ); + + $command = new InteractiveTestCommand(); + $command->setInputReader( $inputReader ); + + // Should not throw exception even though output wasn't set + $result = $command->callConfirm( 'Continue?' ); + + $this->assertTrue( $result ); + } } /** @@ -198,4 +411,46 @@ public function execute(): int { return 0; } +} + +/** + * Test command that exposes protected methods for testing + */ +class InteractiveTestCommand extends Command +{ + public function getName(): string + { + return 'interactive:test'; + } + + public function getDescription(): string + { + return 'Test command for interactive methods'; + } + + public function execute(): int + { + return 0; + } + + // Expose protected methods for testing + public function callPrompt( string $message ): string + { + return $this->prompt( $message ); + } + + public function callConfirm( string $message, bool $default = false ): bool + { + return $this->confirm( $message, $default ); + } + + public function callSecret( string $message ): string + { + return $this->secret( $message ); + } + + public function callChoice( string $message, array $options, ?string $default = null ): string + { + return $this->choice( $message, $options, $default ); + } } \ No newline at end of file diff --git a/tests/Cli/IO/StdinInputReaderTest.php b/tests/Cli/IO/StdinInputReaderTest.php new file mode 100644 index 0000000..bdd228d --- /dev/null +++ b/tests/Cli/IO/StdinInputReaderTest.php @@ -0,0 +1,291 @@ +output = new Output( false ); + $this->reader = new StdinInputReader( $this->output ); + } + + public function testConstructorAcceptsOutput(): void + { + $reader = new StdinInputReader( $this->output ); + + $this->assertInstanceOf( StdinInputReader::class, $reader ); + } + + public function testConfirmWithEmptyResponseUsesDefault(): void + { + // We can test the logic without actual STDIN by using a mock + $mock = $this->getMockBuilder( StdinInputReader::class ) + ->setConstructorArgs( [$this->output] ) + ->onlyMethods( ['prompt'] ) + ->getMock(); + + $mock->expects( $this->once() ) + ->method( 'prompt' ) + ->willReturn( '' ); + + $result = $mock->confirm( 'Continue?', true ); + + $this->assertTrue( $result ); + } + + public function testConfirmAcceptsYesResponse(): void + { + $mock = $this->getMockBuilder( StdinInputReader::class ) + ->setConstructorArgs( [$this->output] ) + ->onlyMethods( ['prompt'] ) + ->getMock(); + + foreach( ['y', 'yes', 'YES', 'Yes', 'true', 'TRUE', '1'] as $response ) { + $mock->expects( $this->once() ) + ->method( 'prompt' ) + ->willReturn( $response ); + + $result = $mock->confirm( 'Continue?', false ); + + $this->assertTrue( $result, "Response '{$response}' should be treated as positive" ); + + // Reset the mock for next iteration + $mock = $this->getMockBuilder( StdinInputReader::class ) + ->setConstructorArgs( [$this->output] ) + ->onlyMethods( ['prompt'] ) + ->getMock(); + } + } + + public function testConfirmRejectsNoResponse(): void + { + $mock = $this->getMockBuilder( StdinInputReader::class ) + ->setConstructorArgs( [$this->output] ) + ->onlyMethods( ['prompt'] ) + ->getMock(); + + foreach( ['n', 'no', 'NO', 'false', 'FALSE', '0', 'nope', 'anything'] as $response ) { + $mock->expects( $this->once() ) + ->method( 'prompt' ) + ->willReturn( $response ); + + $result = $mock->confirm( 'Continue?', true ); + + $this->assertFalse( $result, "Response '{$response}' should be treated as negative" ); + + // Reset the mock for next iteration + $mock = $this->getMockBuilder( StdinInputReader::class ) + ->setConstructorArgs( [$this->output] ) + ->onlyMethods( ['prompt'] ) + ->getMock(); + } + } + + public function testConfirmAddsSuffixBasedOnDefault(): void + { + // Test that the suffix is added correctly based on default value + // We can't easily verify the output, but we can verify the method is called + $mock = $this->getMockBuilder( StdinInputReader::class ) + ->setConstructorArgs( [$this->output] ) + ->onlyMethods( ['prompt'] ) + ->getMock(); + + // Default true should show [Y/n] + $mock->expects( $this->once() ) + ->method( 'prompt' ) + ->with( $this->stringContains( '[Y/n]' ) ) + ->willReturn( '' ); + + $mock->confirm( 'Continue?', true ); + } + + public function testChoiceWithEmptyResponseUsesDefault(): void + { + $mock = $this->getMockBuilder( StdinInputReader::class ) + ->setConstructorArgs( [$this->output] ) + ->onlyMethods( ['prompt'] ) + ->getMock(); + + $mock->expects( $this->once() ) + ->method( 'prompt' ) + ->willReturn( '' ); + + $options = ['dev', 'staging', 'prod']; + $result = $mock->choice( 'Select:', $options, 'dev' ); + + $this->assertEquals( 'dev', $result ); + } + + public function testChoiceWithNumericIndex(): void + { + $mock = $this->getMockBuilder( StdinInputReader::class ) + ->setConstructorArgs( [$this->output] ) + ->onlyMethods( ['prompt'] ) + ->getMock(); + + $mock->expects( $this->once() ) + ->method( 'prompt' ) + ->willReturn( '1' ); + + $options = ['dev', 'staging', 'prod']; + $result = $mock->choice( 'Select:', $options ); + + $this->assertEquals( 'staging', $result ); + } + + public function testChoiceWithExactMatch(): void + { + $mock = $this->getMockBuilder( StdinInputReader::class ) + ->setConstructorArgs( [$this->output] ) + ->onlyMethods( ['prompt'] ) + ->getMock(); + + $mock->expects( $this->once() ) + ->method( 'prompt' ) + ->willReturn( 'staging' ); + + $options = ['dev', 'staging', 'prod']; + $result = $mock->choice( 'Select:', $options ); + + $this->assertEquals( 'staging', $result ); + } + + public function testChoiceWithInvalidSelectionRetries(): void + { + $mock = $this->getMockBuilder( StdinInputReader::class ) + ->setConstructorArgs( [$this->output] ) + ->onlyMethods( ['prompt'] ) + ->getMock(); + + // First call returns invalid, second call returns valid + $mock->expects( $this->exactly( 2 ) ) + ->method( 'prompt' ) + ->willReturnOnConsecutiveCalls( 'invalid', '1' ); + + $options = ['dev', 'staging', 'prod']; + $result = $mock->choice( 'Select:', $options ); + + $this->assertEquals( 'staging', $result ); + } + + public function testSecretDelegatesToPrompt(): void + { + // We can't easily test the actual secret hiding functionality, + // but we can verify the method works + $mock = $this->getMockBuilder( StdinInputReader::class ) + ->setConstructorArgs( [$this->output] ) + ->onlyMethods( ['prompt'] ) + ->getMock(); + + // On non-Windows systems, secret() will internally handle stty, + // but we can't test that easily in unit tests + // This test just verifies the interface works + $this->assertInstanceOf( StdinInputReader::class, $mock ); + } + + public function testSecretMethodExistsAndIsCallable(): void + { + // Verify secret method exists and can be called + $reader = new StdinInputReader( $this->output ); + $reflection = new \ReflectionClass( $reader ); + + $this->assertTrue( $reflection->hasMethod( 'secret' ) ); + $method = $reflection->getMethod( 'secret' ); + $this->assertTrue( $method->isPublic() ); + + // The method should handle both TTY and non-TTY environments gracefully + // We can't easily test actual STDIN reading in unit tests, but we verify + // the method exists and has the correct visibility + } + + public function testIsTtyMethodExists(): void + { + // Use reflection to verify the isTty method exists + $reflection = new \ReflectionClass( StdinInputReader::class ); + + $this->assertTrue( $reflection->hasMethod( 'isTty' ) ); + + $method = $reflection->getMethod( 'isTty' ); + $this->assertTrue( $method->isPrivate() ); + } + + public function testIsTtyReturnsBooleanValue(): void + { + // Use reflection to invoke the private isTty method + $reader = new StdinInputReader( $this->output ); + $reflection = new \ReflectionClass( $reader ); + $method = $reflection->getMethod( 'isTty' ); + $method->setAccessible( true ); + + $result = $method->invoke( $reader ); + + // Should return a boolean value (true or false depending on environment) + $this->assertIsBool( $result ); + } + + public function testChoiceWithMaxRetriesUsesDefault(): void + { + $mock = $this->getMockBuilder( StdinInputReader::class ) + ->setConstructorArgs( [$this->output] ) + ->onlyMethods( ['prompt'] ) + ->getMock(); + + // All responses will be invalid + $mock->expects( $this->exactly( 10 ) ) // MAX_RETRY_ATTEMPTS + ->method( 'prompt' ) + ->willReturn( 'invalid' ); + + $options = ['dev', 'staging', 'prod']; + $result = $mock->choice( 'Select:', $options, 'dev' ); + + // Should fall back to default after max retries + $this->assertEquals( 'dev', $result ); + } + + public function testChoiceWithMaxRetriesThrowsExceptionWithoutDefault(): void + { + $mock = $this->getMockBuilder( StdinInputReader::class ) + ->setConstructorArgs( [$this->output] ) + ->onlyMethods( ['prompt'] ) + ->getMock(); + + // All responses will be invalid + $mock->expects( $this->exactly( 10 ) ) // MAX_RETRY_ATTEMPTS + ->method( 'prompt' ) + ->willReturn( 'invalid' ); + + $options = ['dev', 'staging', 'prod']; + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'Maximum retry attempts exceeded and no default value provided' ); + + $mock->choice( 'Select:', $options ); // No default + } + + public function testChoiceSucceedsBeforeMaxRetries(): void + { + $mock = $this->getMockBuilder( StdinInputReader::class ) + ->setConstructorArgs( [$this->output] ) + ->onlyMethods( ['prompt'] ) + ->getMock(); + + // First 3 invalid, then valid on 4th attempt + $mock->expects( $this->exactly( 4 ) ) + ->method( 'prompt' ) + ->willReturnOnConsecutiveCalls( 'invalid', 'bad', 'nope', 'staging' ); + + $options = ['dev', 'staging', 'prod']; + $result = $mock->choice( 'Select:', $options ); + + // Should succeed with valid response before hitting max retries + $this->assertEquals( 'staging', $result ); + } +} diff --git a/tests/Cli/IO/TestInputReaderTest.php b/tests/Cli/IO/TestInputReaderTest.php new file mode 100644 index 0000000..69f7936 --- /dev/null +++ b/tests/Cli/IO/TestInputReaderTest.php @@ -0,0 +1,208 @@ +reader = new TestInputReader(); + } + + public function testPromptReturnsConfiguredResponse(): void + { + $this->reader->addResponse( 'test response' ); + + $result = $this->reader->prompt( 'Enter something: ' ); + + $this->assertEquals( 'test response', $result ); + } + + public function testPromptTracksHistory(): void + { + $this->reader->addResponse( 'response 1' ); + $this->reader->addResponse( 'response 2' ); + + $this->reader->prompt( 'First prompt: ' ); + $this->reader->prompt( 'Second prompt: ' ); + + $history = $this->reader->getPromptHistory(); + + $this->assertCount( 2, $history ); + $this->assertEquals( 'First prompt: ', $history[0] ); + $this->assertEquals( 'Second prompt: ', $history[1] ); + } + + public function testPromptThrowsExceptionWhenNoResponseConfigured(): void + { + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'No response configured for prompt #0' ); + + $this->reader->prompt( 'Enter something: ' ); + } + + public function testAddResponsesAcceptsArray(): void + { + $this->reader->addResponses( ['response 1', 'response 2', 'response 3'] ); + + $this->assertEquals( 'response 1', $this->reader->prompt( 'First: ' ) ); + $this->assertEquals( 'response 2', $this->reader->prompt( 'Second: ' ) ); + $this->assertEquals( 'response 3', $this->reader->prompt( 'Third: ' ) ); + } + + public function testConfirmReturnsTrue(): void + { + $this->reader->addResponse( 'yes' ); + + $result = $this->reader->confirm( 'Are you sure?' ); + + $this->assertTrue( $result ); + } + + public function testConfirmReturnsFalse(): void + { + $this->reader->addResponse( 'no' ); + + $result = $this->reader->confirm( 'Are you sure?' ); + + $this->assertFalse( $result ); + } + + public function testConfirmAcceptsVariousPositiveResponses(): void + { + foreach( ['y', 'yes', 'YES', 'Yes', 'true', 'TRUE', '1'] as $response ) { + $reader = new TestInputReader(); + $reader->addResponse( $response ); + + $this->assertTrue( + $reader->confirm( 'Continue?' ), + "Response '{$response}' should be treated as positive" + ); + } + } + + public function testConfirmUsesDefault(): void + { + $this->reader->addResponse( '' ); + + $result = $this->reader->confirm( 'Continue?', true ); + + $this->assertTrue( $result ); + } + + public function testSecretReturnsResponse(): void + { + $this->reader->addResponse( 'secret-password' ); + + $result = $this->reader->secret( 'Enter password: ' ); + + $this->assertEquals( 'secret-password', $result ); + } + + public function testChoiceReturnsOptionByIndex(): void + { + $options = ['option1', 'option2', 'option3']; + $this->reader->addResponse( '1' ); + + $result = $this->reader->choice( 'Select:', $options ); + + $this->assertEquals( 'option2', $result ); + } + + public function testChoiceReturnsOptionByName(): void + { + $options = ['development', 'staging', 'production']; + $this->reader->addResponse( 'staging' ); + + $result = $this->reader->choice( 'Select environment:', $options ); + + $this->assertEquals( 'staging', $result ); + } + + public function testChoiceUsesDefault(): void + { + $options = ['dev', 'staging', 'prod']; + $this->reader->addResponse( '' ); + + $result = $this->reader->choice( 'Select:', $options, 'dev' ); + + $this->assertEquals( 'dev', $result ); + } + + public function testHasMoreResponsesReturnsTrueWhenResponsesRemain(): void + { + $this->reader->addResponses( ['response 1', 'response 2'] ); + + $this->assertTrue( $this->reader->hasMoreResponses() ); + + $this->reader->prompt( 'First: ' ); + + $this->assertTrue( $this->reader->hasMoreResponses() ); + + $this->reader->prompt( 'Second: ' ); + + $this->assertFalse( $this->reader->hasMoreResponses() ); + } + + public function testGetRemainingResponseCount(): void + { + $this->reader->addResponses( ['r1', 'r2', 'r3'] ); + + $this->assertEquals( 3, $this->reader->getRemainingResponseCount() ); + + $this->reader->prompt( 'First: ' ); + + $this->assertEquals( 2, $this->reader->getRemainingResponseCount() ); + + $this->reader->prompt( 'Second: ' ); + + $this->assertEquals( 1, $this->reader->getRemainingResponseCount() ); + } + + public function testResetClearsAllData(): void + { + $this->reader->addResponses( ['r1', 'r2'] ); + $this->reader->prompt( 'Test: ' ); + + $this->reader->reset(); + + $this->assertCount( 0, $this->reader->getPromptHistory() ); + $this->assertEquals( 0, $this->reader->getRemainingResponseCount() ); + } + + public function testFluentInterface(): void + { + $result = $this->reader + ->addResponse( 'first' ) + ->addResponse( 'second' ); + + $this->assertInstanceOf( TestInputReader::class, $result ); + $this->assertEquals( 2, $this->reader->getRemainingResponseCount() ); + } + + public function testConfirmTreatsZeroAsNegativeResponse(): void + { + // PHP's empty('0') returns true, but we want '0' to be treated + // as a negative response, not as empty + $this->reader->addResponse( '0' ); + + $result = $this->reader->confirm( 'Continue?', true ); + + $this->assertFalse( $result, "Response '0' should be treated as negative, not as empty" ); + } + + public function testChoiceCanSelectIndexZero(): void + { + // Ensure '0' can be used to select the first option + $this->reader->addResponse( '0' ); + + $result = $this->reader->choice( 'Select:', ['first', 'second', 'third'] ); + + $this->assertEquals( 'first', $result ); + } +} diff --git a/versionlog.md b/versionlog.md index 81d080f..d511cce 100644 --- a/versionlog.md +++ b/versionlog.md @@ -1,9 +1,14 @@ ## 0.8.9 +* Testability refactoring: Added IInputReader abstraction for testing CLI commands with user input. +* Fixed potential stack overflow in StdinInputReader::choice() by replacing recursive retry with iterative loop. +* Added MAX_RETRY_ATTEMPTS constant to prevent infinite loops on invalid input. +* Fixed empty() bug where '0' was incorrectly treated as empty response. +* Added defensive guard in Command::getInputReader() to prevent uninitialized property access by lazily initializing Output when needed. +* CRITICAL: Added exception safety to secret() method with try-finally to ensure terminal echo is always restored, preventing broken terminal states. +* CRITICAL: Added TTY check (isTty() method) to prevent stty errors in non-interactive environments (CI/CD, piped input, automated scripts). ## 0.8.8 2025-11-28 - ## 0.8.7 2025-11-24 - ## 0.8.6 2025-11-22 * Added the initializer scaffold command.