diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1c8f7..51bfa6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`Prompter::canPrompt(?InputInterface $input = null)`** says whether a prompt asked now can + reach a person: not under a non-interactive input, yes when prompts are answered through + Laravel's question-helper fallback (Windows, the test harness), and otherwise only when stdin + is a terminal. Without a terminal laravel/prompts returns each prompt's default instead of + asking, so a prompt with no usable default fails with "Required."; a command can check first + and name its non-interactive alternative. A package no longer has to reach into + `Laravel\Prompts\SelectPrompt` to ask. +- **`InteractsWithConsole::withPromptFallback()`** turns on Laravel's fakeable prompt fallback, + for a test that switches the app out of the `testing` environment and still answers a + command's prompts with `expectsQuestion()`. + ### Fixed - **`Live` and `TaskProgress` never rendered live inside a command.** Both tested diff --git a/docs/tools/prompter.md b/docs/tools/prompter.md index 7a49efd..f4bb4f3 100644 --- a/docs/tools/prompter.md +++ b/docs/tools/prompter.md @@ -39,6 +39,37 @@ prompter()->context()->warning('Heads up'); prompter()->number('Port', default: 8080)->getResult(); // newer prompts helpers too ``` +### Checking that a prompt can be answered + +`Prompter::canPrompt()` says whether a prompt asked now can reach a person. Without a +terminal, laravel/prompts stops asking and returns each prompt's default, so a prompt +with no usable default fails with "Required.". Check before a loop of prompts and point +the user at the non-interactive alternative: + +```php +use Simtabi\Laranail\Console\Prompter\Prompter; + +public function handle(): int +{ + if (! Prompter::canPrompt($this->input)) { + $this->error('acme:setup needs an interactive terminal. Use acme:setup:apply instead.'); + + return self::INVALID; + } + + $driver = Prompter::create()->select('Driver', ['mysql', 'pgsql'])->getResult(); + // ... +} +``` + +| Situation | Result | +|---|---| +| `$input` is non-interactive (`--no-interaction`, or no terminal on stdin) | `false` | +| Prompts are answered through Laravel's question-helper fallback (Windows, the test harness) | `true` | +| Otherwise | whether stdin is a terminal | + +`$input` is optional; without it only the last two rows apply. + ## Forms `prompter()->form()` returns a raw `Laravel\Prompts\FormBuilder`. For richer, diff --git a/docs/tools/testing.md b/docs/tools/testing.md index 8169091..d6bbbf2 100644 --- a/docs/tools/testing.md +++ b/docs/tools/testing.md @@ -56,6 +56,24 @@ $name = Console::prompter()->text('Your name?')->getResult(); self::assertSame('Imani', $name); ``` +### Answering a command's prompts outside the `testing` environment + +`Illuminate\Console\Command` routes prompts through its fakeable question-helper +fallback only on Windows or while the app runs unit tests. A test that switches the +app to another environment loses it, and `expectsQuestion()` then has nothing to +answer. `withPromptFallback()` turns the fallback back on: + +```php +$this->app['env'] = 'production'; +$this->withPromptFallback(); + +$this->artisan('acme:setup') + ->expectsQuestion('Driver', 'mysql') + ->assertExitCode(0); +``` + +laravel/prompts keeps the flag for the rest of the process; it cannot be turned off. + ## Without the trait The same primitives are available directly: diff --git a/src/Prompter/Prompter.php b/src/Prompter/Prompter.php index 1bcf07c..a7631e3 100644 --- a/src/Prompter/Prompter.php +++ b/src/Prompter/Prompter.php @@ -6,10 +6,12 @@ use Closure; use Laravel\Prompts\FormBuilder; +use Laravel\Prompts\SelectPrompt; use function Laravel\Prompts\form; use Illuminate\Support\Collection; +use Symfony\Component\Console\Input\InputInterface; use Simtabi\Laranail\Console\Prompter\Services\PromptService; use Simtabi\Laranail\Console\Prompter\Exceptions\PrompterException; use Simtabi\Laranail\Console\Prompter\Services\Components\ContextBuilderService; @@ -109,6 +111,35 @@ public static function create(): self return new self; } + /** + * Whether a prompt asked now can reach a person. + * + * - Not when `$input` is non-interactive: `--no-interaction`, or Symfony found + * no terminal on stdin. + * - Yes when prompts are answered through Laravel's question-helper fallback, + * which `Illuminate\Console\Command` turns on for Windows and under the test + * harness. Laravel registers that fallback for every prompt together, so + * `SelectPrompt` answers for all of them. + * - Otherwise only when stdin is a terminal. That is the check laravel/prompts + * makes before it stops asking and returns each prompt's default instead, so a + * prompt with no usable default fails with "Required.". + * + * Ask this before a loop of prompts, and fail with a message naming the + * non-interactive alternative when it is false. + */ + public static function canPrompt(?InputInterface $input = null): bool + { + if ($input instanceof InputInterface && ! $input->isInteractive()) { + return false; + } + + if (SelectPrompt::shouldFallback()) { + return true; + } + + return defined('STDIN') && stream_isatty(STDIN); + } + /** * Provides access to context-related methods. */ diff --git a/src/Testing/InteractsWithConsole.php b/src/Testing/InteractsWithConsole.php index 416ee7e..6f2679e 100644 --- a/src/Testing/InteractsWithConsole.php +++ b/src/Testing/InteractsWithConsole.php @@ -47,6 +47,21 @@ protected function withPromptInput(array $keys = []): void Prompt::fake($keys); } + /** + * Route prompts through Laravel's fakeable question-helper fallback, so a + * command's prompts can be answered with `expectsQuestion()` and friends. + * + * `Illuminate\Console\Command` enables the fallback itself only on Windows or + * while the app runs unit tests, so a test that switches the app to another + * environment (`production`, say) needs this to keep its prompts fakeable. + * laravel/prompts keeps the flag for the rest of the process: it cannot be + * turned back off. + */ + protected function withPromptFallback(): void + { + Prompt::fallbackWhen(true); + } + #[After] protected function restoreConsoleCapabilities(): void { diff --git a/tests/Prompter/PrompterCanPromptTest.php b/tests/Prompter/PrompterCanPromptTest.php new file mode 100644 index 0000000..d5b27a8 --- /dev/null +++ b/tests/Prompter/PrompterCanPromptTest.php @@ -0,0 +1,116 @@ + */ + private array $fallbacks; + + protected function setUp(): void + { + parent::setUp(); + + // laravel/prompts keeps its fallback state in statics that only ever turn on, + // so snapshot them and put them back: each case here sets its own. + $this->shouldFallback = (bool) $this->fallbackFlag()->getValue(); + $this->fallbacks = (array) $this->fallbackMap()->getValue(); + } + + protected function tearDown(): void + { + $this->fallbackFlag()->setValue(null, $this->shouldFallback); + $this->fallbackMap()->setValue(null, $this->fallbacks); + + parent::tearDown(); + } + + public function test_non_interactive_input_cannot_prompt_even_with_the_fallback_on(): void + { + $this->enableFallback(); + $input = new ArrayInput([]); + $input->setInteractive(false); + + self::assertFalse(Prompter::canPrompt($input)); + } + + public function test_the_question_helper_fallback_can_prompt_without_a_terminal(): void + { + $this->enableFallback(); + + self::assertTrue(Prompter::canPrompt(new ArrayInput([]))); + self::assertTrue(Prompter::canPrompt()); + } + + public function test_an_enabled_flag_without_a_registered_fallback_is_not_enough(): void + { + $this->fallbackFlag()->setValue(null, true); + $this->fallbackMap()->setValue(null, []); + + self::assertSame($this->stdinIsTerminal(), Prompter::canPrompt(new ArrayInput([]))); + } + + public function test_without_the_fallback_it_follows_whether_stdin_is_a_terminal(): void + { + $this->fallbackFlag()->setValue(null, false); + + self::assertSame($this->stdinIsTerminal(), Prompter::canPrompt(new ArrayInput([]))); + } + + public function test_it_cannot_prompt_when_stdin_is_not_a_terminal(): void + { + if (DIRECTORY_SEPARATOR === '\\' || ! function_exists('proc_open')) { + self::markTestSkipped('Needs a POSIX shell with proc_open.'); + } + + // A fresh process, as a cron job or CI step sees it: stdin is /dev/null and + // no command has registered the fallback. + $code = 'require $argv[1]; echo var_export(' . Prompter::class . '::canPrompt(new ' + . ArrayInput::class . '([])), true);'; + $process = proc_open( + [PHP_BINARY, '-r', $code, dirname(__DIR__, 2) . '/vendor/autoload.php'], + [['file', '/dev/null', 'r'], ['pipe', 'w'], ['pipe', 'w']], + $pipes, + ); + self::assertIsResource($process); + + $stdout = (string) stream_get_contents($pipes[1]); + $stderr = (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + self::assertSame(0, proc_close($process), $stderr); + self::assertSame('false', $stdout); + } + + private function enableFallback(): void + { + SelectPrompt::fallbackUsing(static fn (SelectPrompt $prompt): string => 'answered'); + Prompt::fallbackWhen(true); + } + + private function stdinIsTerminal(): bool + { + return defined('STDIN') && stream_isatty(STDIN); + } + + private function fallbackFlag(): ReflectionProperty + { + return new ReflectionProperty(Prompt::class, 'shouldFallback'); + } + + private function fallbackMap(): ReflectionProperty + { + return new ReflectionProperty(Prompt::class, 'fallbacks'); + } +} diff --git a/tests/Tools/Testing/InteractsWithConsoleTest.php b/tests/Tools/Testing/InteractsWithConsoleTest.php index 1444dce..94351ed 100644 --- a/tests/Tools/Testing/InteractsWithConsoleTest.php +++ b/tests/Tools/Testing/InteractsWithConsoleTest.php @@ -4,6 +4,8 @@ namespace Simtabi\Laranail\Console\Tools\Tests\Testing; +use ReflectionProperty; +use Laravel\Prompts\Prompt; use PHPUnit\Framework\TestCase; use Simtabi\Laranail\Console\Tools\Widgets\Box; use Simtabi\Laranail\Console\Tools\Support\Capabilities; @@ -45,4 +47,19 @@ public function test_clear_fake_restores_detection(): void Capabilities::clearFake(); self::assertIsBool(Capabilities::detect()->supportsUnicode()); } + + public function test_with_prompt_fallback_turns_on_the_fakeable_fallback(): void + { + $flag = new ReflectionProperty(Prompt::class, 'shouldFallback'); + $before = $flag->getValue(); + $flag->setValue(null, false); + + try { + $this->withPromptFallback(); + + self::assertTrue($flag->getValue()); + } finally { + $flag->setValue(null, $before); + } + } }