diff --git a/composer.json b/composer.json index 437a9eada..d61f24b9e 100644 --- a/composer.json +++ b/composer.json @@ -77,6 +77,9 @@ "tempest/view": "self.version", "tempest/vite": "self.version" }, + "suggest": { + "ext-pcntl": "Required to use some interactive console components." + }, "minimum-stability": "dev", "prefer-stable": true, "autoload": { diff --git a/src/Tempest/Console/src/Components/ComponentState.php b/src/Tempest/Console/src/Components/ComponentState.php index 43a8f37a7..0bb899a68 100644 --- a/src/Tempest/Console/src/Components/ComponentState.php +++ b/src/Tempest/Console/src/Components/ComponentState.php @@ -30,4 +30,12 @@ enum ComponentState * Input is blocked. */ case BLOCKED; + + public function isFinished(): bool + { + return match ($this) { + self::ACTIVE, self::ERROR, self::BLOCKED => false, + default => true, + }; + } } diff --git a/src/Tempest/Console/src/Components/Interactive/TaskComponent.php b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php new file mode 100644 index 000000000..6fc327a94 --- /dev/null +++ b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php @@ -0,0 +1,186 @@ +handler = $this->resolveHandler($handler); + $this->renderer = new TaskRenderer(new SpinnerRenderer(), $label); + $this->startedAt = hrtime(as_number: true); + } + + public function render(Terminal $terminal): Generator + { + // If there is no task handler, we don't need to fork the process, as + // it is a time-consuming operation. We can simply consider it done. + if ($this->handler === null) { + $this->state = ComponentState::SUBMITTED; + + yield $this->renderTask($terminal); + + return true; + } + + $this->sockets = stream_socket_pair(domain: STREAM_PF_UNIX, type: STREAM_SOCK_STREAM, protocol: STREAM_IPPROTO_IP); + $this->processId = pcntl_fork(); + + if ($this->processId === -1) { + throw new RuntimeException('Could not fork process'); + } + + if (! $this->processId) { + $this->executeHandler(); + } + + try { + fclose($this->sockets[0]); + stream_set_blocking($this->sockets[1], enable: false); + + while (true) { + // The process is still running, so we continue looping. + if (pcntl_waitpid($this->processId, $status, flags: WNOHANG) === 0) { + yield $this->renderTask( + terminal: $terminal, + line: fread($this->sockets[1], length: 1024) ?: null, + ); + + usleep($this->renderer->delay()); + + continue; + } + + // The process is done, we register the finishing timestamp, + // close the communication socket and determine the finished state. + fclose($this->sockets[1]); + $this->finishedAt = hrtime(as_number: true); + $this->state = match (pcntl_wifexited($status)) { + true => match (pcntl_wexitstatus($status)) { + 0 => ComponentState::SUBMITTED, + default => ComponentState::ERROR, + }, + default => ComponentState::CANCELLED, + }; + + yield $this->renderTask($terminal); + + return $this->state === ComponentState::SUBMITTED; + } + } finally { + if ($this->state->isFinished() && $this->processId) { + posix_kill($this->processId, SIGTERM); + } + + $this->cleanupSockets(); + } + } + + private function renderTask(Terminal $terminal, ?string $line = null): string + { + if ($line) { + $this->log[] = $line; + } + + return $this->renderer->render( + terminal: $terminal, + state: $this->state, + startedAt: $this->startedAt, + finishedAt: $this->finishedAt, + hint: end($this->log) ?: null, + ); + } + + private function cleanupSockets(): void + { + foreach ($this->sockets as $socket) { + if (is_resource($socket)) { + @fclose($socket); + } + } + + $this->sockets = []; + } + + private function executeHandler(): void + { + $log = function (string ...$lines): void { + arr($lines) + ->flatMap(fn (string $line) => explode("\n", $line)) + ->each(function (string $line): void { + fwrite($this->sockets[0], $line); + }); + }; + + try { + exit((int) (($this->handler ?? static fn (): bool => true)($log) === false)); + } catch (Throwable) { + exit(1); + } + } + + private function resolveHandler(null|Process|Closure $handler): ?Closure + { + if ($handler === null) { + return null; + } + + if ($handler instanceof Process) { + return static function (Closure $log) use ($handler): bool { + return $handler->run(function (string $output, string $buffer) use ($log): bool { + if ($output === Process::ERR) { + return true; + } + + if ($line = trim($buffer)) { + $log($buffer); + } + + return true; + }) === 0; + }; + } + + return $handler; + } + + public function renderFooter(Terminal $terminal): ?string + { + return null; + } +} diff --git a/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php b/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php index fea620836..7ac565844 100644 --- a/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php +++ b/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php @@ -16,6 +16,7 @@ use Tempest\Validation\Exceptions\InvalidValueException; use Tempest\Validation\Rule; use Tempest\Validation\Validator; +use function Tempest\Support\arr; final class InteractiveComponentRenderer { @@ -153,6 +154,8 @@ private function applyKey(InteractiveConsoleComponent $component, Console $conso continue; } + Fiber::suspend(); + // If valid, we can return return $return; } @@ -231,6 +234,19 @@ private function validate(mixed $value, array $validation): ?Rule return null; } + public function isComponentSupported(Console $console, InteractiveConsoleComponent $component): bool + { + if (! arr($component->extensions ?? [])->every(fn (string $ext) => extension_loaded($ext))) { + return false; + } + + if (! new Terminal($console)->supportsTty()) { + return false; + } + + return true; + } + private function createTerminal(Console $console): Terminal { $terminal = new Terminal($console); diff --git a/src/Tempest/Console/src/Components/Renderers/SpinnerRenderer.php b/src/Tempest/Console/src/Components/Renderers/SpinnerRenderer.php new file mode 100644 index 000000000..b8419b82a --- /dev/null +++ b/src/Tempest/Console/src/Components/Renderers/SpinnerRenderer.php @@ -0,0 +1,31 @@ +index; + + $this->index = ($this->index + 1) % count(self::FRAMES); + + return self::FRAMES[$previous]; + } +} diff --git a/src/Tempest/Console/src/Components/Renderers/TaskRenderer.php b/src/Tempest/Console/src/Components/Renderers/TaskRenderer.php new file mode 100644 index 000000000..11d30a89f --- /dev/null +++ b/src/Tempest/Console/src/Components/Renderers/TaskRenderer.php @@ -0,0 +1,62 @@ +prepareRender($terminal, $state); + $this->label($this->label); + + $runtime = fn (float $finishedAt) => $finishedAt + ? number_format(($finishedAt - $startedAt) / 1_000_000, decimals: 0) + : null; + + $hint = match ($this->state) { + ComponentState::ERROR => 'An error occurred.', + ComponentState::CANCELLED => 'Cancelled.', + ComponentState::SUBMITTED => $finishedAt + ? 'Done in '.$runtime($finishedAt).'ms.' + : 'Done.', + default => $hint ?? $runtime(hrtime(as_number: true)) . 'ms', + }; + + $this->line( + append: str() + ->append(match ($this->state) { + ComponentState::SUBMITTED => '✔', + ComponentState::ERROR => '✖', + ComponentState::CANCELLED => '⚠', + default => ''.$this->spinner->render($terminal, $this->state).'', + }) + ->append(' ', $hint, ''), + ); + + // If a task has an error, it is no longer active. + if (in_array($this->state, [ComponentState::ACTIVE, ComponentState::CANCELLED])) { + $this->newLine(); + } + + return $this->finishRender(); + } + + public function delay(): int + { + return $this->spinner->speed; + } +} diff --git a/src/Tempest/Console/src/Console.php b/src/Tempest/Console/src/Console.php index ceae91792..eb20d4422 100644 --- a/src/Tempest/Console/src/Console.php +++ b/src/Tempest/Console/src/Console.php @@ -7,6 +7,7 @@ use BackedEnum; use Closure; use Stringable; +use Symfony\Component\Process\Process; use Tempest\Highlight\Language; use Tempest\Support\ArrayHelper; @@ -58,6 +59,8 @@ public function progressBar(iterable $data, Closure $handler): array; */ public function search(string $label, Closure $search, bool $multiple = false, null|string|array $default = null): mixed; + public function task(string $label, null|Process|Closure $handler): bool; + public function header(string $header, ?string $subheader = null): self; public function info(string $line, ?string $symbol = null): self; diff --git a/src/Tempest/Console/src/GenericConsole.php b/src/Tempest/Console/src/GenericConsole.php index edc1525ab..7971e94af 100644 --- a/src/Tempest/Console/src/GenericConsole.php +++ b/src/Tempest/Console/src/GenericConsole.php @@ -7,6 +7,7 @@ use BackedEnum; use Closure; use Stringable; +use Symfony\Component\Process\Process; use Tempest\Console\Actions\ExecuteConsoleCommand; use Tempest\Console\Components\Interactive\ConfirmComponent; use Tempest\Console\Components\Interactive\MultipleChoiceComponent; @@ -14,6 +15,7 @@ use Tempest\Console\Components\Interactive\ProgressBarComponent; use Tempest\Console\Components\Interactive\SearchComponent; use Tempest\Console\Components\Interactive\SingleChoiceComponent; +use Tempest\Console\Components\Interactive\TaskComponent; use Tempest\Console\Components\Interactive\TextInputComponent; use Tempest\Console\Components\InteractiveComponentRenderer; use Tempest\Console\Exceptions\UnsupportedComponent; @@ -183,7 +185,7 @@ public function withLabel(string $label): self public function component(InteractiveConsoleComponent $component, array $validation = []): mixed { - if ($this->componentRenderer !== null) { + if ($this->componentRenderer !== null && $this->componentRenderer->isComponentSupported($this, $component)) { return $this->componentRenderer->render($this, $component, $validation); } @@ -274,6 +276,11 @@ public function progressBar(iterable $data, Closure $handler): array return $this->component(new ProgressBarComponent($data, $handler)); } + public function task(string $label, null|Process|Closure $handler = null): bool + { + return $this->component(new TaskComponent($label, $handler)); + } + public function search(string $label, Closure $search, bool $multiple = false, null|string|array $default = null): mixed { return $this->component(new SearchComponent($label, $search, $multiple, $default)); diff --git a/tests/Integration/Console/Components/TaskComponentTest.php b/tests/Integration/Console/Components/TaskComponentTest.php new file mode 100644 index 000000000..009f9d51c --- /dev/null +++ b/tests/Integration/Console/Components/TaskComponentTest.php @@ -0,0 +1,88 @@ +markTestSkipped('These tests require the pcntl extension, which is not available on Windows.'); + } + + if (! $this->container->get(DatabaseConfig::class)->connection instanceof SQLiteConnection) { + $this->markTestSkipped('These tests duplicate PDO connections due to `pnctl_fork`, so they are skipped until the framework supports closing connections.'); + } + } + + public function test_no_task(): void + { + $this->console->withoutPrompting()->call(function (Console $console): void { + $terminal = new Terminal($console); + $component = new TaskComponent('Task in progress'); + + $frames = iterator_to_array($component->render($terminal)); + + $this->assertStringContainsString('Task in progress', $frames[0]); + $this->assertStringContainsString('Done.', $frames[0]); + }); + } + + public function test_process_task(): void + { + $this->console->withoutPrompting()->call(function (Console $console): void { + $terminal = new Terminal($console); + $process = new Process(['echo', 'hello world']); + $component = new TaskComponent('Task in progress', $process); + + $frames = iterator_to_array($component->render($terminal)); + + $this->assertStringContainsString('Task in progress', $frames[0]); + $this->assertStringContainsString('Done in', $frames[1]); + }); + } + + public function test_successful_task(): void + { + $this->console->withoutPrompting()->call(function (Console $console): void { + $terminal = new Terminal($console); + $component = new TaskComponent('Task in progress', function (): void {}); + + $frames = iterator_to_array($component->render($terminal)); + + $this->assertStringContainsString('Task in progress', $frames[0]); + $this->assertStringContainsString('Done in', $frames[1]); + }); + } + + public function test_failing_task(): void + { + $this->console->withoutPrompting()->call(function (Console $console): void { + $terminal = new Terminal($console); + $component = new TaskComponent('Task in progress', function (): never { + throw new Exception('Failure'); + }); + + $frames = iterator_to_array($component->render($terminal)); + + $this->assertStringContainsString('Task in progress', $frames[0]); + $this->assertStringContainsString('An error occurred.', $frames[1]); + }); + } +}