From eee42c991925835a4f3609e40313106d2fd2cfe1 Mon Sep 17 00:00:00 2001 From: Lee Jones Date: Tue, 30 Dec 2025 11:14:03 -0600 Subject: [PATCH 1/3] testability --- readme.md | 144 ++++++++++++++++++- src/Cli/Commands/Command.php | 122 ++++++++++++++++- src/Cli/IO/IInputReader.php | 59 ++++++++ src/Cli/IO/StdinInputReader.php | 121 ++++++++++++++++ src/Cli/IO/TestInputReader.php | 198 +++++++++++++++++++++++++++ tests/Cli/IO/TestInputReaderTest.php | 187 +++++++++++++++++++++++++ versionlog.md | 3 +- 7 files changed, 827 insertions(+), 7 deletions(-) create mode 100644 src/Cli/IO/IInputReader.php create mode 100644 src/Cli/IO/StdinInputReader.php create mode 100644 src/Cli/IO/TestInputReader.php create mode 100644 tests/Cli/IO/TestInputReaderTest.php 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..f054c11 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,121 @@ 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. + * + * @return IInputReader + */ + protected function getInputReader(): IInputReader + { + if( !$this->inputReader ) { + $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..e66f94f --- /dev/null +++ b/src/Cli/IO/StdinInputReader.php @@ -0,0 +1,121 @@ +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( empty( $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 ); + + // Only hide input on Unix-like systems + if( strtoupper( substr( PHP_OS, 0, 3 ) ) !== 'WIN' ) { + // Disable terminal echo + system( 'stty -echo' ); + $input = fgets( STDIN ); + // Re-enable terminal echo + system( 'stty echo' ); + // Add newline since user's enter key wasn't echoed + $this->output->writeln( '' ); + } else { + // On Windows, fall back to visible input + // A proper Windows implementation would use COM or other Windows-specific methods + $input = fgets( STDIN ); + } + + return $input !== false ? trim( $input ) : ''; + } + + /** + * @inheritDoc + */ + public function choice( string $message, array $options, ?string $default = null ): string + { + // Display the prompt message + $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( empty( $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 - ask again + $this->output->error( "Invalid choice. Please try again." ); + $this->output->writeln( '' ); + + return $this->choice( $message, $options, $default ); + } +} diff --git a/src/Cli/IO/TestInputReader.php b/src/Cli/IO/TestInputReader.php new file mode 100644 index 0000000..f61233e --- /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( empty( $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( empty( $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/IO/TestInputReaderTest.php b/tests/Cli/IO/TestInputReaderTest.php new file mode 100644 index 0000000..f54fc68 --- /dev/null +++ b/tests/Cli/IO/TestInputReaderTest.php @@ -0,0 +1,187 @@ +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() ); + } +} diff --git a/versionlog.md b/versionlog.md index 81d080f..16a2124 100644 --- a/versionlog.md +++ b/versionlog.md @@ -1,9 +1,8 @@ ## 0.8.9 +* Testability refactoring. ## 0.8.8 2025-11-28 - ## 0.8.7 2025-11-24 - ## 0.8.6 2025-11-22 * Added the initializer scaffold command. From 230bf04c06f4c889ccb00c1d4de5cacd299d44c0 Mon Sep 17 00:00:00 2001 From: Lee Jones Date: Tue, 30 Dec 2025 11:21:39 -0600 Subject: [PATCH 2/3] tests --- src/Cli/IO/StdinInputReader.php | 4 +- src/Cli/IO/TestInputReader.php | 4 +- tests/Cli/Commands/CommandTest.php | 204 ++++++++++++++++++++++++++ tests/Cli/IO/StdinInputReaderTest.php | 193 ++++++++++++++++++++++++ tests/Cli/IO/TestInputReaderTest.php | 21 +++ 5 files changed, 422 insertions(+), 4 deletions(-) create mode 100644 tests/Cli/IO/StdinInputReaderTest.php diff --git a/src/Cli/IO/StdinInputReader.php b/src/Cli/IO/StdinInputReader.php index e66f94f..0a9bd05 100644 --- a/src/Cli/IO/StdinInputReader.php +++ b/src/Cli/IO/StdinInputReader.php @@ -40,7 +40,7 @@ public function confirm( string $message, bool $default = false ): bool $response = $this->prompt( $message . $suffix ); // If user just presses enter, use default - if( empty( $response ) ) { + if( $response === '' ) { return $default; } @@ -95,7 +95,7 @@ public function choice( string $message, array $options, ?string $default = null $response = $this->prompt( $prompt ); // If user just presses enter and there's a default, use it - if( empty( $response ) && $default !== null ) { + if( $response === '' && $default !== null ) { return $default; } diff --git a/src/Cli/IO/TestInputReader.php b/src/Cli/IO/TestInputReader.php index f61233e..bdb7102 100644 --- a/src/Cli/IO/TestInputReader.php +++ b/src/Cli/IO/TestInputReader.php @@ -148,7 +148,7 @@ public function confirm( string $message, bool $default = false ): bool $response = $this->prompt( $message ); // If response is empty, use default - if( empty( $response ) ) { + if( $response === '' ) { return $default; } @@ -174,7 +174,7 @@ public function choice( string $message, array $options, ?string $default = null $response = $this->prompt( $message ); // If response is empty and there's a default, use it - if( empty( $response ) && $default !== null ) { + if( $response === '' && $default !== null ) { return $default; } diff --git a/tests/Cli/Commands/CommandTest.php b/tests/Cli/Commands/CommandTest.php index 0cd1b99..6c39e37 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,166 @@ 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 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 ); + } } /** @@ -198,4 +360,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..08ef945 --- /dev/null +++ b/tests/Cli/IO/StdinInputReaderTest.php @@ -0,0 +1,193 @@ +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 testChoiceWithInvalidSelectionRetriesOnce(): 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 ); + } +} diff --git a/tests/Cli/IO/TestInputReaderTest.php b/tests/Cli/IO/TestInputReaderTest.php index f54fc68..69f7936 100644 --- a/tests/Cli/IO/TestInputReaderTest.php +++ b/tests/Cli/IO/TestInputReaderTest.php @@ -184,4 +184,25 @@ public function testFluentInterface(): void $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 ); + } } From 7364c845f1431c8d70830514f752f2054287edaa Mon Sep 17 00:00:00 2001 From: Lee Jones Date: Tue, 30 Dec 2025 11:36:20 -0600 Subject: [PATCH 3/3] fixes --- src/Cli/Commands/Command.php | 7 ++ src/Cli/IO/StdinInputReader.php | 135 ++++++++++++++++++-------- tests/Cli/Commands/CommandTest.php | 51 ++++++++++ tests/Cli/IO/StdinInputReaderTest.php | 100 ++++++++++++++++++- versionlog.md | 8 +- 5 files changed, 260 insertions(+), 41 deletions(-) diff --git a/src/Cli/Commands/Command.php b/src/Cli/Commands/Command.php index f054c11..ee854d1 100644 --- a/src/Cli/Commands/Command.php +++ b/src/Cli/Commands/Command.php @@ -311,11 +311,18 @@ public function validate(): void * 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 ); } diff --git a/src/Cli/IO/StdinInputReader.php b/src/Cli/IO/StdinInputReader.php index 0a9bd05..ddecd5a 100644 --- a/src/Cli/IO/StdinInputReader.php +++ b/src/Cli/IO/StdinInputReader.php @@ -14,6 +14,13 @@ */ class StdinInputReader implements IInputReader { + /** + * Maximum number of retry attempts for invalid input. + * After this many failed attempts, the method will fall back to the default + * or throw an exception if no default is provided. + */ + private const MAX_RETRY_ATTEMPTS = 10; + /** * @param Output $output Output instance for displaying prompts */ @@ -55,67 +62,117 @@ public function secret( string $message ): string { $this->output->write( $message, false ); - // Only hide input on Unix-like systems - if( strtoupper( substr( PHP_OS, 0, 3 ) ) !== 'WIN' ) { - // Disable terminal echo - system( 'stty -echo' ); - $input = fgets( STDIN ); - // Re-enable terminal echo - system( 'stty echo' ); + // 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 { - // On Windows, fall back to visible input - // A proper Windows implementation would use COM or other Windows-specific methods + // 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 { - // Display the prompt message - $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}" ); - } + $attempts = 0; - $this->output->writeln( '' ); + while( $attempts < self::MAX_RETRY_ATTEMPTS ) { + // Display the prompt message (only on first attempt) + if( $attempts === 0 ) { + $this->output->writeln( $message ); + $this->output->writeln( '' ); - // Prompt for selection - $prompt = $default !== null ? "Choice [{$default}]: " : "Choice: "; - $response = $this->prompt( $prompt ); + // Display options with index numbers + foreach( $options as $index => $option ) { + $marker = ($default === $option) ? '*' : ' '; + $this->output->writeln( " [{$marker}] {$index}. {$option}" ); + } - // If user just presses enter and there's a default, use it - if( $response === '' && $default !== null ) { - return $default; - } + $this->output->writeln( '' ); + } - // Check if response is a numeric index - if( is_numeric( $response ) ) { - $index = (int)$response; - if( isset( $options[$index] ) ) { - return $options[$index]; + // 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 matches an option exactly - if( in_array( $response, $options, true ) ) { - return $response; + // 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( '' ); + } } - // Invalid choice - ask again - $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; + } - return $this->choice( $message, $options, $default ); + throw new \RuntimeException( + 'Maximum retry attempts exceeded and no default value provided for choice prompt' + ); } } diff --git a/tests/Cli/Commands/CommandTest.php b/tests/Cli/Commands/CommandTest.php index 6c39e37..0ba3e53 100644 --- a/tests/Cli/Commands/CommandTest.php +++ b/tests/Cli/Commands/CommandTest.php @@ -202,6 +202,27 @@ public function testGetInputReaderCreatesDefaultWhenNotSet(): void $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(); @@ -330,6 +351,36 @@ public function testChoiceWithDefault(): void $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 ); + } } /** diff --git a/tests/Cli/IO/StdinInputReaderTest.php b/tests/Cli/IO/StdinInputReaderTest.php index 08ef945..bdd228d 100644 --- a/tests/Cli/IO/StdinInputReaderTest.php +++ b/tests/Cli/IO/StdinInputReaderTest.php @@ -158,7 +158,7 @@ public function testChoiceWithExactMatch(): void $this->assertEquals( 'staging', $result ); } - public function testChoiceWithInvalidSelectionRetriesOnce(): void + public function testChoiceWithInvalidSelectionRetries(): void { $mock = $this->getMockBuilder( StdinInputReader::class ) ->setConstructorArgs( [$this->output] ) @@ -190,4 +190,102 @@ public function testSecretDelegatesToPrompt(): void // 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/versionlog.md b/versionlog.md index 16a2124..d511cce 100644 --- a/versionlog.md +++ b/versionlog.md @@ -1,5 +1,11 @@ ## 0.8.9 -* Testability refactoring. +* 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