Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions docs/tools/prompter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions docs/tools/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
31 changes: 31 additions & 0 deletions src/Prompter/Prompter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down
15 changes: 15 additions & 0 deletions src/Testing/InteractsWithConsole.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
116 changes: 116 additions & 0 deletions tests/Prompter/PrompterCanPromptTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\Console\Prompter\Tests;

use ReflectionProperty;
use Laravel\Prompts\Prompt;
use Laravel\Prompts\SelectPrompt;
use Simtabi\Laranail\Console\Prompter\Prompter;
use Symfony\Component\Console\Input\ArrayInput;

final class PrompterCanPromptTest extends TestCase
{
private bool $shouldFallback;

/** @var array<class-string, mixed> */
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');
}
}
17 changes: 17 additions & 0 deletions tests/Tools/Testing/InteractsWithConsoleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
}
Loading