From c9d52856508c7f5e5ed739f8efe3f20effd907ad Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Fri, 13 Dec 2024 15:25:04 +0100 Subject: [PATCH 01/11] refactor(console): rename submitted component state to `done` --- src/Tempest/Console/src/Components/ComponentState.php | 2 +- src/Tempest/Console/src/Components/Concerns/HasState.php | 2 +- .../Console/src/Components/Concerns/HasTextInputRenderer.php | 2 +- src/Tempest/Console/src/Components/Concerns/RendersControls.php | 2 +- src/Tempest/Console/src/Components/Renderers/ChoiceRenderer.php | 2 +- .../Console/src/Components/Renderers/ConfirmRenderer.php | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Tempest/Console/src/Components/ComponentState.php b/src/Tempest/Console/src/Components/ComponentState.php index 43a8f37a7..ad54a6992 100644 --- a/src/Tempest/Console/src/Components/ComponentState.php +++ b/src/Tempest/Console/src/Components/ComponentState.php @@ -24,7 +24,7 @@ enum ComponentState /** * Input was submitted. */ - case SUBMITTED; + case DONE; /** * Input is blocked. diff --git a/src/Tempest/Console/src/Components/Concerns/HasState.php b/src/Tempest/Console/src/Components/Concerns/HasState.php index df4338922..093e18a51 100644 --- a/src/Tempest/Console/src/Components/Concerns/HasState.php +++ b/src/Tempest/Console/src/Components/Concerns/HasState.php @@ -30,6 +30,6 @@ public function setState(ComponentState $state): void #[HandlesKey(Key::ENTER)] public function setSubmitted(): void { - $this->state = ComponentState::SUBMITTED; + $this->state = ComponentState::DONE; } } diff --git a/src/Tempest/Console/src/Components/Concerns/HasTextInputRenderer.php b/src/Tempest/Console/src/Components/Concerns/HasTextInputRenderer.php index b97a9812d..37857dbab 100644 --- a/src/Tempest/Console/src/Components/Concerns/HasTextInputRenderer.php +++ b/src/Tempest/Console/src/Components/Concerns/HasTextInputRenderer.php @@ -43,7 +43,7 @@ public function altEnter(): ?string return null; } - $this->state = ComponentState::SUBMITTED; + $this->state = ComponentState::DONE; return $this->buffer->text ?? ''; } diff --git a/src/Tempest/Console/src/Components/Concerns/RendersControls.php b/src/Tempest/Console/src/Components/Concerns/RendersControls.php index 3169524aa..87fdd5d72 100644 --- a/src/Tempest/Console/src/Components/Concerns/RendersControls.php +++ b/src/Tempest/Console/src/Components/Concerns/RendersControls.php @@ -18,7 +18,7 @@ trait RendersControls { public function renderFooter(Terminal $terminal): ?string { - if (in_array($this->getState(), [ComponentState::CANCELLED, ComponentState::SUBMITTED, ComponentState::BLOCKED])) { + if (in_array($this->getState(), [ComponentState::CANCELLED, ComponentState::DONE, ComponentState::BLOCKED])) { return null; } diff --git a/src/Tempest/Console/src/Components/Renderers/ChoiceRenderer.php b/src/Tempest/Console/src/Components/Renderers/ChoiceRenderer.php index 56cbd5e37..357cd9cd8 100644 --- a/src/Tempest/Console/src/Components/Renderers/ChoiceRenderer.php +++ b/src/Tempest/Console/src/Components/Renderers/ChoiceRenderer.php @@ -33,7 +33,7 @@ public function render( $this->prepareRender($terminal, $state); $this->label($label); - if ($state === ComponentState::SUBMITTED) { + if ($state === ComponentState::DONE) { $this->line( $this->multiple ? '' . count($options->getSelectedOptions()) . ' selected' diff --git a/src/Tempest/Console/src/Components/Renderers/ConfirmRenderer.php b/src/Tempest/Console/src/Components/Renderers/ConfirmRenderer.php index f84da4994..3b4bc32df 100644 --- a/src/Tempest/Console/src/Components/Renderers/ConfirmRenderer.php +++ b/src/Tempest/Console/src/Components/Renderers/ConfirmRenderer.php @@ -30,7 +30,7 @@ public function render( $this->newLine(border: true); match ($this->state) { - ComponentState::SUBMITTED => $this->line( + ComponentState::DONE => $this->line( $this->style($answer === true ? 'bg-green bold' : 'bg-red bold', $this->centerText($answer ? $this->yes : $this->no, width: 9)), "\n", ), From 4142ee7230b002187731c711af133b9edb64f33f Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Fri, 13 Dec 2024 15:54:54 +0100 Subject: [PATCH 02/11] feat(console): add `keyValue` and `task` methods --- composer.json | 3 + .../Components/Interactive/TaskComponent.php | 150 ++++++++++++++++++ .../InteractiveComponentRenderer.php | 14 ++ .../Components/Renderers/KeyValueRenderer.php | 49 ++++++ .../Components/Renderers/SpinnerRenderer.php | 46 ++++++ src/Tempest/Console/src/Console.php | 4 + src/Tempest/Console/src/GenericConsole.php | 14 +- .../Console/Components/TaskComponentTest.php | 59 +++++++ .../Renderers/KeyValueRendererTest.php | 30 ++++ 9 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 src/Tempest/Console/src/Components/Interactive/TaskComponent.php create mode 100644 src/Tempest/Console/src/Components/Renderers/KeyValueRenderer.php create mode 100644 src/Tempest/Console/src/Components/Renderers/SpinnerRenderer.php create mode 100644 tests/Integration/Console/Components/TaskComponentTest.php create mode 100644 tests/Integration/Console/Renderers/KeyValueRendererTest.php 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/Interactive/TaskComponent.php b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php new file mode 100644 index 000000000..b3b1ef919 --- /dev/null +++ b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php @@ -0,0 +1,150 @@ +spinner = new SpinnerRenderer($label); + $this->keyValue = new KeyValueRenderer(); + $this->startedAt = hrtime(as_number: true); + } + + public function render(Terminal $terminal): Generator + { + if ($this->handler === null) { + $this->state = ComponentState::DONE; + + yield $this->renderLine($terminal); + + return true; + } + + $this->processId = pcntl_fork(); + + if ($this->processId === -1) { + throw new RuntimeException('Could not fork process'); + } + + if (! $this->processId) { + $this->executeHandler(); + } + + try { + while (true) { + if (pcntl_waitpid($this->processId, $status, flags: WNOHANG) === 0) { + yield $this->renderLine($terminal); + + usleep($this->spinner->speed); + + continue; + } + + $this->finishedAt = hrtime(as_number: true); + $this->state = match (pcntl_wifexited($status)) { + true => match (pcntl_wexitstatus($status)) { + 0 => ComponentState::DONE, + default => ComponentState::ERROR, + }, + default => ComponentState::CANCELLED, + }; + + yield $this->renderLine($terminal); + + return $this->state === ComponentState::DONE; + } + } finally { + if ($this->state !== ComponentState::DONE && $this->processId) { + $this->kill(); + } + } + } + + private function renderLine(Terminal $terminal): string + { + $runtime = $this->finishedAt + ? number_format(($this->finishedAt - $this->startedAt) / 1_000_000, decimals: 0) + : null; + + $line = $this->keyValue->render( + key: str() + ->append(match ($this->state) { + ComponentState::DONE => '✔', + ComponentState::ERROR => '✖', + ComponentState::CANCELLED => '⚠', + default => $this->spinner->render($terminal, $this->state), + }) + ->append(' ', $this->label), + value: $this->state !== ComponentState::ACTIVE + ? sprintf( + '%s%s', + $runtime ? ($runtime . 'ms ') : '', + match ($this->state) { + ComponentState::DONE => 'fg-green', + default => 'fg-red', + }, + match ($this->state) { + ComponentState::DONE => $this->success ?? 'DONE', + default => $this->failure ?? 'FAIL', + }, + ) + : null, + ); + + return $line . ($this->finishedAt ? '' : "\n"); + } + + private function kill(): void + { + posix_kill($this->processId, SIGTERM); + } + + private function executeHandler(): void + { + try { + exit((int) (($this->handler ?? static fn (): bool => true)() === false)); + } catch (Throwable) { + exit(1); + } + } + + 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..fc02dfcd5 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 { @@ -231,6 +232,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/KeyValueRenderer.php b/src/Tempest/Console/src/Components/Renderers/KeyValueRenderer.php new file mode 100644 index 000000000..b55e35e87 --- /dev/null +++ b/src/Tempest/Console/src/Components/Renderers/KeyValueRenderer.php @@ -0,0 +1,49 @@ +cleanText($key)->append(' '); + $value = $this->cleanText($value)->unless( + condition: fn ($s) => $s->stripTags()->length() === 0, + callback: fn ($s) => $s->prepend(' '), + ); + + $dotsWidth = $maximumWidth - $key->stripTags()->length() - $value->stripTags()->length(); + + return str() + ->append($key) + ->append('', str_repeat('.', max(self::MIN_WIDTH, min($dotsWidth, self::MAX_WIDTH))), '') + ->append($value) + ->toString(); + } + + private function cleanText(null|Stringable|string $text): StringHelper + { + $text = str($text)->trim(); + + if ($text->length() === 0) { + return str(); + } + + return $text + ->replaceRegex('/\[([^]]+)]/', '[$1]') + ->when(fn ($s) => $s->endsWith(['.', '?', '!', ':']), fn ($s) => $s->replaceAt(-1, 1, '')) + ->replace(root_path(), '') + ->trim(); + } +} 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..c04827a12 --- /dev/null +++ b/src/Tempest/Console/src/Components/Renderers/SpinnerRenderer.php @@ -0,0 +1,46 @@ +index; + + $this->index = ($this->index + 1) % count(self::FRAMES); + + return self::FRAMES[$previous]; + + return str() + ->append("\n") + ->append($margin) + ->append('', self::FRAMES[$previous], '') + ->append(' ') + ->append(str($this->label)->truncate(min(150, $terminal->width - strlen($margin) * 2))) + ->append("\n") + ->toString(); + } +} diff --git a/src/Tempest/Console/src/Console.php b/src/Tempest/Console/src/Console.php index ceae91792..dd7c23927 100644 --- a/src/Tempest/Console/src/Console.php +++ b/src/Tempest/Console/src/Console.php @@ -58,6 +58,10 @@ 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, Closure $handler): bool; + + public function keyValue(string $key, ?string $value = null): void; + 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..ee47b1c9f 100644 --- a/src/Tempest/Console/src/GenericConsole.php +++ b/src/Tempest/Console/src/GenericConsole.php @@ -14,8 +14,10 @@ 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\Components\Renderers\KeyValueRenderer; use Tempest\Console\Exceptions\UnsupportedComponent; use Tempest\Console\Highlight\TempestConsoleLanguage\TempestConsoleLanguage; use Tempest\Console\Input\ConsoleArgumentBag; @@ -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,16 @@ public function progressBar(iterable $data, Closure $handler): array return $this->component(new ProgressBarComponent($data, $handler)); } + public function task(string $label, ?Closure $handler = null): bool + { + return $this->component(new TaskComponent($label, $handler)); + } + + public function keyValue(string $key, ?string $value = null): void + { + $this->writeln((new KeyValueRenderer())->render($key, $value)); + } + 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..ce745c61d --- /dev/null +++ b/tests/Integration/Console/Components/TaskComponentTest.php @@ -0,0 +1,59 @@ +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[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('ms', $frames[1]); // execution time + $this->assertStringContainsString('DONE', $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('FAIL', $frames[1]); + }); + } +} diff --git a/tests/Integration/Console/Renderers/KeyValueRendererTest.php b/tests/Integration/Console/Renderers/KeyValueRendererTest.php new file mode 100644 index 000000000..af03f6e26 --- /dev/null +++ b/tests/Integration/Console/Renderers/KeyValueRendererTest.php @@ -0,0 +1,30 @@ +render('Foo', 'bar'); + + $this->assertSame('Foo ..................................................................................................................... bar', $rendered); + } + + public function test_render_total_width_smaller_than_text(): void + { + $renderer = new KeyValueRenderer(); + $rendered = $renderer->render('Some long text', 'Some long text', maximumWidth: 0); + + $this->assertSame('Some long text ... Some long text', $rendered); + } +} From 18ee25260c05b2945416c790e0c7f348a6381ed6 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Fri, 13 Dec 2024 16:13:49 +0100 Subject: [PATCH 03/11] fix(console): always rerender before finishing interaction --- .../src/Components/InteractiveComponentRenderer.php | 7 ++++++- .../Console/src/Components/Renderers/RendersInput.php | 4 ++++ tests/Integration/Console/Components/TaskComponentTest.php | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php b/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php index fc02dfcd5..4fda27bc0 100644 --- a/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php +++ b/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php @@ -70,7 +70,7 @@ private function renderComponent(Console $console, InteractiveConsoleComponent $ $this->closeTerminal($terminal); } - return null; + return $render; } private function applyKey(InteractiveConsoleComponent $component, Console $console, array $validation): mixed @@ -148,12 +148,17 @@ private function applyKey(InteractiveConsoleComponent $component, Console $conso // If invalid, we'll remember the validation message and continue if ($failingRule !== null) { + $component->setState(ComponentState::ERROR); $this->validationErrors[] = $failingRule->message(); Fiber::suspend(); continue; } + if ($this->shouldRerender === true) { + Fiber::suspend(); + } + // If valid, we can return return $return; } diff --git a/src/Tempest/Console/src/Components/Renderers/RendersInput.php b/src/Tempest/Console/src/Components/Renderers/RendersInput.php index 4bc893e9f..edbfc37e4 100644 --- a/src/Tempest/Console/src/Components/Renderers/RendersInput.php +++ b/src/Tempest/Console/src/Components/Renderers/RendersInput.php @@ -53,6 +53,10 @@ private function prepareRender(Terminal $terminal, ComponentState $state): self private function finishRender(): string { + if ($this->state === ComponentState::DONE && $this->frame->endsWith("\n")) { + $this->frame = $this->frame->replaceEnd("\n", ''); + } + return $this->frame->toString(); } diff --git a/tests/Integration/Console/Components/TaskComponentTest.php b/tests/Integration/Console/Components/TaskComponentTest.php index ce745c61d..10fbd7011 100644 --- a/tests/Integration/Console/Components/TaskComponentTest.php +++ b/tests/Integration/Console/Components/TaskComponentTest.php @@ -24,7 +24,7 @@ public function test_no_task(): void $frames = iterator_to_array($component->render($terminal)); $this->assertStringContainsString('Task in progress', $frames[0]); - $this->assertStringContainsString('DONE', $frames[1]); + $this->assertStringContainsString('DONE', $frames[0]); }); } From 418ee6d315faf005c9565dbf14e302fea1278957 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Fri, 13 Dec 2024 16:31:53 +0100 Subject: [PATCH 04/11] chore: apply fixes from qa --- .../Components/Interactive/TaskComponent.php | 2 +- .../Components/InteractiveComponentRenderer.php | 6 ++---- .../Components/Renderers/SpinnerRenderer.php | 17 +---------------- 3 files changed, 4 insertions(+), 21 deletions(-) diff --git a/src/Tempest/Console/src/Components/Interactive/TaskComponent.php b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php index b3b1ef919..aa41d661d 100644 --- a/src/Tempest/Console/src/Components/Interactive/TaskComponent.php +++ b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php @@ -40,7 +40,7 @@ public function __construct( private readonly ?string $success = null, private readonly ?string $failure = null, ) { - $this->spinner = new SpinnerRenderer($label); + $this->spinner = new SpinnerRenderer(); $this->keyValue = new KeyValueRenderer(); $this->startedAt = hrtime(as_number: true); } diff --git a/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php b/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php index 4fda27bc0..9bb1a1471 100644 --- a/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php +++ b/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php @@ -70,7 +70,7 @@ private function renderComponent(Console $console, InteractiveConsoleComponent $ $this->closeTerminal($terminal); } - return $render; + return null; } private function applyKey(InteractiveConsoleComponent $component, Console $console, array $validation): mixed @@ -155,9 +155,7 @@ private function applyKey(InteractiveConsoleComponent $component, Console $conso continue; } - if ($this->shouldRerender === true) { - Fiber::suspend(); - } + Fiber::suspend(); // If valid, we can return return $return; diff --git a/src/Tempest/Console/src/Components/Renderers/SpinnerRenderer.php b/src/Tempest/Console/src/Components/Renderers/SpinnerRenderer.php index c04827a12..b8419b82a 100644 --- a/src/Tempest/Console/src/Components/Renderers/SpinnerRenderer.php +++ b/src/Tempest/Console/src/Components/Renderers/SpinnerRenderer.php @@ -6,21 +6,15 @@ use Tempest\Console\Components\ComponentState; use Tempest\Console\Terminal\Terminal; -use function Tempest\Support\str; final class SpinnerRenderer { - private const array FRAMES = [ '⠁', '⠉', '⠙', '⠚', '⠒', '⠂', '⠂', '⠒', '⠲', '⠴', '⠤', '⠄', '⠄', '⠤', '⠴', '⠲', '⠒', '⠂', '⠂', '⠒', '⠚', '⠙', '⠉', '⠁']; + private const array FRAMES = ['⠁', '⠁', '⠉', '⠙', '⠚', '⠒', '⠂', '⠂', '⠒', '⠲', '⠴', '⠤', '⠄', '⠄', '⠤', '⠠', '⠠', '⠤', '⠦', '⠖', '⠒', '⠐', '⠐', '⠒', '⠓', '⠋', '⠉', '⠈', '⠈']; private int $index = 0; private(set) int $speed = 80_000; - public function __construct( - private readonly string $label, - ) { - } - public function render(Terminal $terminal, ComponentState $state): string { if ($state !== ComponentState::ACTIVE) { @@ -33,14 +27,5 @@ public function render(Terminal $terminal, ComponentState $state): string $this->index = ($this->index + 1) % count(self::FRAMES); return self::FRAMES[$previous]; - - return str() - ->append("\n") - ->append($margin) - ->append('', self::FRAMES[$previous], '') - ->append(' ') - ->append(str($this->label)->truncate(min(150, $terminal->width - strlen($margin) * 2))) - ->append("\n") - ->toString(); } } From 0166b365258d408116e4bc4932ef14684a7b814c Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Fri, 13 Dec 2024 17:01:59 +0100 Subject: [PATCH 05/11] ci: skip tests on Windows --- .../Integration/Console/Components/TaskComponentTest.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/Integration/Console/Components/TaskComponentTest.php b/tests/Integration/Console/Components/TaskComponentTest.php index 10fbd7011..c1612c8dc 100644 --- a/tests/Integration/Console/Components/TaskComponentTest.php +++ b/tests/Integration/Console/Components/TaskComponentTest.php @@ -15,6 +15,15 @@ */ final class TaskComponentTest extends FrameworkIntegrationTestCase { + protected function setUp(): void + { + parent::setUp(); + + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('These tests require the pcntl extension, which is not available on Windows.'); + } + } + public function test_no_task(): void { $this->console->withoutPrompting()->call(function (Console $console): void { From 81261e04a0c6db569f8f4f5e3cb7f6b275c2605f Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 15 Dec 2024 17:26:29 +0100 Subject: [PATCH 06/11] refactor: update tasks design --- .../Console/src/Components/ComponentState.php | 8 +++ .../Components/Interactive/TaskComponent.php | 59 +++++++------------ .../InteractiveComponentRenderer.php | 1 - .../src/Components/Renderers/TaskRenderer.php | 55 +++++++++++++++++ 4 files changed, 83 insertions(+), 40 deletions(-) create mode 100644 src/Tempest/Console/src/Components/Renderers/TaskRenderer.php diff --git a/src/Tempest/Console/src/Components/ComponentState.php b/src/Tempest/Console/src/Components/ComponentState.php index ad54a6992..65b78710b 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 index aa41d661d..1d3d3c136 100644 --- a/src/Tempest/Console/src/Components/Interactive/TaskComponent.php +++ b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php @@ -12,20 +12,20 @@ use Tempest\Console\Components\Concerns\HasState; use Tempest\Console\Components\Renderers\KeyValueRenderer; use Tempest\Console\Components\Renderers\SpinnerRenderer; +use Tempest\Console\Components\Renderers\TaskRenderer; use Tempest\Console\InteractiveConsoleComponent; use Tempest\Console\Terminal\Terminal; use Throwable; -use function Tempest\Support\str; final class TaskComponent implements InteractiveConsoleComponent { use HasErrors; use HasState; - private SpinnerRenderer $spinner; - private KeyValueRenderer $keyValue; + private TaskRenderer $renderer; + private int $processId; private float $startedAt; @@ -40,17 +40,19 @@ public function __construct( private readonly ?string $success = null, private readonly ?string $failure = null, ) { - $this->spinner = new SpinnerRenderer(); $this->keyValue = new KeyValueRenderer(); + $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::DONE; - yield $this->renderLine($terminal); + yield $this->renderTask($terminal); return true; } @@ -67,14 +69,16 @@ public function render(Terminal $terminal): Generator try { while (true) { + // The process is still running, so we continue looping. if (pcntl_waitpid($this->processId, $status, flags: WNOHANG) === 0) { - yield $this->renderLine($terminal); + yield $this->renderTask($terminal); - usleep($this->spinner->speed); + usleep(80_000); continue; } + // The process is done, we determine its state by its exit code. $this->finishedAt = hrtime(as_number: true); $this->state = match (pcntl_wifexited($status)) { true => match (pcntl_wexitstatus($status)) { @@ -84,49 +88,26 @@ public function render(Terminal $terminal): Generator default => ComponentState::CANCELLED, }; - yield $this->renderLine($terminal); + yield $this->renderTask($terminal); return $this->state === ComponentState::DONE; } } finally { - if ($this->state !== ComponentState::DONE && $this->processId) { + if ($this->state->isFinished() && $this->processId) { $this->kill(); } } } - private function renderLine(Terminal $terminal): string + private function renderTask(Terminal $terminal): string { - $runtime = $this->finishedAt - ? number_format(($this->finishedAt - $this->startedAt) / 1_000_000, decimals: 0) - : null; - - $line = $this->keyValue->render( - key: str() - ->append(match ($this->state) { - ComponentState::DONE => '✔', - ComponentState::ERROR => '✖', - ComponentState::CANCELLED => '⚠', - default => $this->spinner->render($terminal, $this->state), - }) - ->append(' ', $this->label), - value: $this->state !== ComponentState::ACTIVE - ? sprintf( - '%s%s', - $runtime ? ($runtime . 'ms ') : '', - match ($this->state) { - ComponentState::DONE => 'fg-green', - default => 'fg-red', - }, - match ($this->state) { - ComponentState::DONE => $this->success ?? 'DONE', - default => $this->failure ?? 'FAIL', - }, - ) - : null, + return $this->renderer->render( + terminal: $terminal, + state: $this->state, + startedAt: $this->startedAt, + finishedAt: $this->finishedAt, + hint: '...', ); - - return $line . ($this->finishedAt ? '' : "\n"); } private function kill(): void diff --git a/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php b/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php index 9bb1a1471..7ac565844 100644 --- a/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php +++ b/src/Tempest/Console/src/Components/InteractiveComponentRenderer.php @@ -148,7 +148,6 @@ private function applyKey(InteractiveConsoleComponent $component, Console $conso // If invalid, we'll remember the validation message and continue if ($failingRule !== null) { - $component->setState(ComponentState::ERROR); $this->validationErrors[] = $failingRule->message(); Fiber::suspend(); 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..e73aaa6c8 --- /dev/null +++ b/src/Tempest/Console/src/Components/Renderers/TaskRenderer.php @@ -0,0 +1,55 @@ +prepareRender($terminal, $state); + $this->label($this->label); + + $runtime = fn (float $finishedAt) => $finishedAt + ? number_format(($finishedAt - $startedAt) / 1_000_000, decimals: 0) + : null; + + $hint = $content ?? match ($this->state) { + ComponentState::DONE => 'Done in '.$runtime($finishedAt).'ms.', + ComponentState::ERROR => 'An error occurred.', + ComponentState::CANCELLED => 'Cancelled.', + default => $runtime(hrtime(as_number: true)) . 'ms', + }; + + $this->line( + append: str() + ->append(match ($this->state) { + ComponentState::DONE => '✔', + 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 (! $state->isFinished() && $this->state !== ComponentState::ERROR) { + $this->newLine(); + } + + return $this->finishRender(); + } +} From 3be366a9fe2c3b9a0f94ca8f345697a21748d784 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 15 Dec 2024 19:22:50 +0100 Subject: [PATCH 07/11] feat: support communicating with child process --- .../Components/Interactive/TaskComponent.php | 74 +++++++++++++++++-- .../src/Components/Renderers/TaskRenderer.php | 15 +++- src/Tempest/Console/src/Console.php | 3 +- src/Tempest/Console/src/GenericConsole.php | 3 +- .../Console/Components/TaskComponentTest.php | 22 +++++- 5 files changed, 99 insertions(+), 18 deletions(-) diff --git a/src/Tempest/Console/src/Components/Interactive/TaskComponent.php b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php index 1d3d3c136..89bc8c869 100644 --- a/src/Tempest/Console/src/Components/Interactive/TaskComponent.php +++ b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php @@ -7,6 +7,7 @@ use Closure; use Generator; use RuntimeException; +use Symfony\Component\Process\Process; use Tempest\Console\Components\ComponentState; use Tempest\Console\Components\Concerns\HasErrors; use Tempest\Console\Components\Concerns\HasState; @@ -16,6 +17,7 @@ use Tempest\Console\InteractiveConsoleComponent; use Tempest\Console\Terminal\Terminal; use Throwable; +use function Tempest\Support\arr; final class TaskComponent implements InteractiveConsoleComponent { @@ -32,14 +34,19 @@ final class TaskComponent implements InteractiveConsoleComponent private ?float $finishedAt = null; + private array $sockets; + + private array $log = []; + private(set) array $extensions = ['pcntl']; public function __construct( private readonly string $label, - private readonly ?Closure $handler = null, + private null|Process|Closure $handler = null, private readonly ?string $success = null, private readonly ?string $failure = null, ) { + $this->handler = $this->resolveHandler($handler); $this->keyValue = new KeyValueRenderer(); $this->renderer = new TaskRenderer(new SpinnerRenderer(), $label); $this->startedAt = hrtime(as_number: true); @@ -57,6 +64,7 @@ public function render(Terminal $terminal): Generator 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) { @@ -68,17 +76,25 @@ public function render(Terminal $terminal): Generator } 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); + yield $this->renderTask( + terminal: $terminal, + line: fread($this->sockets[1], length: 1024) ?: null, + ); - usleep(80_000); + usleep($this->renderer->delay()); continue; } - // The process is done, we determine its state by its exit code. + // 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)) { @@ -99,31 +115,73 @@ public function render(Terminal $terminal): Generator } } - private function renderTask(Terminal $terminal): string + 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: '...', + hint: end($this->log) ?: null, ); } private function kill(): void { - posix_kill($this->processId, SIGTERM); + try { + posix_kill($this->processId, SIGTERM); + @fclose($this->sockets[0]); + @fclose($this->sockets[1]); + } catch (Throwable) { + } } private function executeHandler(): void { + $log = function (string ...$lines): void { + arr($lines) + ->flatMap(fn (string $line) => explode("\n", $line)) + ->each(fn (string $line) => fwrite($this->sockets[0], $line)); + }; + try { - exit((int) (($this->handler ?? static fn (): bool => true)() === false)); + exit((int) (($this->handler ?? static fn (): bool => true)($log) === false)); } catch (Throwable) { exit(1); + } finally { + socket_close($conn); } } + 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/Renderers/TaskRenderer.php b/src/Tempest/Console/src/Components/Renderers/TaskRenderer.php index e73aaa6c8..9a1209e0b 100644 --- a/src/Tempest/Console/src/Components/Renderers/TaskRenderer.php +++ b/src/Tempest/Console/src/Components/Renderers/TaskRenderer.php @@ -27,11 +27,13 @@ public function render(Terminal $terminal, ComponentState $state, float $started ? number_format(($finishedAt - $startedAt) / 1_000_000, decimals: 0) : null; - $hint = $content ?? match ($this->state) { - ComponentState::DONE => 'Done in '.$runtime($finishedAt).'ms.', + $hint = match ($this->state) { ComponentState::ERROR => 'An error occurred.', ComponentState::CANCELLED => 'Cancelled.', - default => $runtime(hrtime(as_number: true)) . 'ms', + ComponentState::DONE => $finishedAt + ? 'Done in '.$runtime($finishedAt).'ms.' + : 'Done.', + default => $hint ?? $runtime(hrtime(as_number: true)) . 'ms', }; $this->line( @@ -46,10 +48,15 @@ public function render(Terminal $terminal, ComponentState $state, float $started ); // If a task has an error, it is no longer active. - if (! $state->isFinished() && $this->state !== ComponentState::ERROR) { + 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 dd7c23927..a2124e59a 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,7 +59,7 @@ 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, Closure $handler): bool; + public function task(string $label, null|Process|Closure $handler): bool; public function keyValue(string $key, ?string $value = null): void; diff --git a/src/Tempest/Console/src/GenericConsole.php b/src/Tempest/Console/src/GenericConsole.php index ee47b1c9f..ea61b6116 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; @@ -276,7 +277,7 @@ public function progressBar(iterable $data, Closure $handler): array return $this->component(new ProgressBarComponent($data, $handler)); } - public function task(string $label, ?Closure $handler = null): bool + public function task(string $label, null|Process|Closure $handler = null): bool { return $this->component(new TaskComponent($label, $handler)); } diff --git a/tests/Integration/Console/Components/TaskComponentTest.php b/tests/Integration/Console/Components/TaskComponentTest.php index c1612c8dc..cd72be28b 100644 --- a/tests/Integration/Console/Components/TaskComponentTest.php +++ b/tests/Integration/Console/Components/TaskComponentTest.php @@ -5,6 +5,7 @@ namespace Tests\Tempest\Integration\Console\Components; use Exception; +use Symfony\Component\Process\Process; use Tempest\Console\Components\Interactive\TaskComponent; use Tempest\Console\Console; use Tempest\Console\Terminal\Terminal; @@ -33,7 +34,21 @@ public function test_no_task(): void $frames = iterator_to_array($component->render($terminal)); $this->assertStringContainsString('Task in progress', $frames[0]); - $this->assertStringContainsString('DONE', $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]); }); } @@ -46,8 +61,7 @@ public function test_successful_task(): void $frames = iterator_to_array($component->render($terminal)); $this->assertStringContainsString('Task in progress', $frames[0]); - $this->assertStringContainsString('ms', $frames[1]); // execution time - $this->assertStringContainsString('DONE', $frames[1]); + $this->assertStringContainsString('Done in', $frames[1]); }); } @@ -62,7 +76,7 @@ public function test_failing_task(): void $frames = iterator_to_array($component->render($terminal)); $this->assertStringContainsString('Task in progress', $frames[0]); - $this->assertStringContainsString('FAIL', $frames[1]); + $this->assertStringContainsString('An error occurred.', $frames[1]); }); } } From b2a3ffcbfbe23c6a49d1e934b3ba4d055287c8aa Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 16 Dec 2024 22:22:12 +0100 Subject: [PATCH 08/11] refactcor: remove `keyValue` and some extra changes --- .../Console/src/Components/ComponentState.php | 2 +- .../src/Components/Concerns/HasState.php | 2 +- .../Concerns/HasTextInputRenderer.php | 2 +- .../Components/Concerns/RendersControls.php | 2 +- .../Components/Interactive/TaskComponent.php | 6 +-- .../Components/Renderers/ChoiceRenderer.php | 2 +- .../Components/Renderers/ConfirmRenderer.php | 2 +- .../Components/Renderers/KeyValueRenderer.php | 49 ------------------- .../src/Components/Renderers/RendersInput.php | 4 -- .../src/Components/Renderers/TaskRenderer.php | 4 +- src/Tempest/Console/src/Console.php | 2 - src/Tempest/Console/src/GenericConsole.php | 6 --- .../Renderers/KeyValueRendererTest.php | 30 ------------ 13 files changed, 11 insertions(+), 102 deletions(-) delete mode 100644 src/Tempest/Console/src/Components/Renderers/KeyValueRenderer.php delete mode 100644 tests/Integration/Console/Renderers/KeyValueRendererTest.php diff --git a/src/Tempest/Console/src/Components/ComponentState.php b/src/Tempest/Console/src/Components/ComponentState.php index 65b78710b..0bb899a68 100644 --- a/src/Tempest/Console/src/Components/ComponentState.php +++ b/src/Tempest/Console/src/Components/ComponentState.php @@ -24,7 +24,7 @@ enum ComponentState /** * Input was submitted. */ - case DONE; + case SUBMITTED; /** * Input is blocked. diff --git a/src/Tempest/Console/src/Components/Concerns/HasState.php b/src/Tempest/Console/src/Components/Concerns/HasState.php index 093e18a51..df4338922 100644 --- a/src/Tempest/Console/src/Components/Concerns/HasState.php +++ b/src/Tempest/Console/src/Components/Concerns/HasState.php @@ -30,6 +30,6 @@ public function setState(ComponentState $state): void #[HandlesKey(Key::ENTER)] public function setSubmitted(): void { - $this->state = ComponentState::DONE; + $this->state = ComponentState::SUBMITTED; } } diff --git a/src/Tempest/Console/src/Components/Concerns/HasTextInputRenderer.php b/src/Tempest/Console/src/Components/Concerns/HasTextInputRenderer.php index 37857dbab..b97a9812d 100644 --- a/src/Tempest/Console/src/Components/Concerns/HasTextInputRenderer.php +++ b/src/Tempest/Console/src/Components/Concerns/HasTextInputRenderer.php @@ -43,7 +43,7 @@ public function altEnter(): ?string return null; } - $this->state = ComponentState::DONE; + $this->state = ComponentState::SUBMITTED; return $this->buffer->text ?? ''; } diff --git a/src/Tempest/Console/src/Components/Concerns/RendersControls.php b/src/Tempest/Console/src/Components/Concerns/RendersControls.php index 87fdd5d72..3169524aa 100644 --- a/src/Tempest/Console/src/Components/Concerns/RendersControls.php +++ b/src/Tempest/Console/src/Components/Concerns/RendersControls.php @@ -18,7 +18,7 @@ trait RendersControls { public function renderFooter(Terminal $terminal): ?string { - if (in_array($this->getState(), [ComponentState::CANCELLED, ComponentState::DONE, ComponentState::BLOCKED])) { + if (in_array($this->getState(), [ComponentState::CANCELLED, ComponentState::SUBMITTED, ComponentState::BLOCKED])) { return null; } diff --git a/src/Tempest/Console/src/Components/Interactive/TaskComponent.php b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php index 89bc8c869..0342e2b16 100644 --- a/src/Tempest/Console/src/Components/Interactive/TaskComponent.php +++ b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php @@ -57,7 +57,7 @@ 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::DONE; + $this->state = ComponentState::SUBMITTED; yield $this->renderTask($terminal); @@ -98,7 +98,7 @@ public function render(Terminal $terminal): Generator $this->finishedAt = hrtime(as_number: true); $this->state = match (pcntl_wifexited($status)) { true => match (pcntl_wexitstatus($status)) { - 0 => ComponentState::DONE, + 0 => ComponentState::SUBMITTED, default => ComponentState::ERROR, }, default => ComponentState::CANCELLED, @@ -106,7 +106,7 @@ public function render(Terminal $terminal): Generator yield $this->renderTask($terminal); - return $this->state === ComponentState::DONE; + return $this->state === ComponentState::SUBMITTED; } } finally { if ($this->state->isFinished() && $this->processId) { diff --git a/src/Tempest/Console/src/Components/Renderers/ChoiceRenderer.php b/src/Tempest/Console/src/Components/Renderers/ChoiceRenderer.php index 357cd9cd8..56cbd5e37 100644 --- a/src/Tempest/Console/src/Components/Renderers/ChoiceRenderer.php +++ b/src/Tempest/Console/src/Components/Renderers/ChoiceRenderer.php @@ -33,7 +33,7 @@ public function render( $this->prepareRender($terminal, $state); $this->label($label); - if ($state === ComponentState::DONE) { + if ($state === ComponentState::SUBMITTED) { $this->line( $this->multiple ? '' . count($options->getSelectedOptions()) . ' selected' diff --git a/src/Tempest/Console/src/Components/Renderers/ConfirmRenderer.php b/src/Tempest/Console/src/Components/Renderers/ConfirmRenderer.php index 3b4bc32df..f84da4994 100644 --- a/src/Tempest/Console/src/Components/Renderers/ConfirmRenderer.php +++ b/src/Tempest/Console/src/Components/Renderers/ConfirmRenderer.php @@ -30,7 +30,7 @@ public function render( $this->newLine(border: true); match ($this->state) { - ComponentState::DONE => $this->line( + ComponentState::SUBMITTED => $this->line( $this->style($answer === true ? 'bg-green bold' : 'bg-red bold', $this->centerText($answer ? $this->yes : $this->no, width: 9)), "\n", ), diff --git a/src/Tempest/Console/src/Components/Renderers/KeyValueRenderer.php b/src/Tempest/Console/src/Components/Renderers/KeyValueRenderer.php deleted file mode 100644 index b55e35e87..000000000 --- a/src/Tempest/Console/src/Components/Renderers/KeyValueRenderer.php +++ /dev/null @@ -1,49 +0,0 @@ -cleanText($key)->append(' '); - $value = $this->cleanText($value)->unless( - condition: fn ($s) => $s->stripTags()->length() === 0, - callback: fn ($s) => $s->prepend(' '), - ); - - $dotsWidth = $maximumWidth - $key->stripTags()->length() - $value->stripTags()->length(); - - return str() - ->append($key) - ->append('', str_repeat('.', max(self::MIN_WIDTH, min($dotsWidth, self::MAX_WIDTH))), '') - ->append($value) - ->toString(); - } - - private function cleanText(null|Stringable|string $text): StringHelper - { - $text = str($text)->trim(); - - if ($text->length() === 0) { - return str(); - } - - return $text - ->replaceRegex('/\[([^]]+)]/', '[$1]') - ->when(fn ($s) => $s->endsWith(['.', '?', '!', ':']), fn ($s) => $s->replaceAt(-1, 1, '')) - ->replace(root_path(), '') - ->trim(); - } -} diff --git a/src/Tempest/Console/src/Components/Renderers/RendersInput.php b/src/Tempest/Console/src/Components/Renderers/RendersInput.php index edbfc37e4..4bc893e9f 100644 --- a/src/Tempest/Console/src/Components/Renderers/RendersInput.php +++ b/src/Tempest/Console/src/Components/Renderers/RendersInput.php @@ -53,10 +53,6 @@ private function prepareRender(Terminal $terminal, ComponentState $state): self private function finishRender(): string { - if ($this->state === ComponentState::DONE && $this->frame->endsWith("\n")) { - $this->frame = $this->frame->replaceEnd("\n", ''); - } - return $this->frame->toString(); } diff --git a/src/Tempest/Console/src/Components/Renderers/TaskRenderer.php b/src/Tempest/Console/src/Components/Renderers/TaskRenderer.php index 9a1209e0b..11d30a89f 100644 --- a/src/Tempest/Console/src/Components/Renderers/TaskRenderer.php +++ b/src/Tempest/Console/src/Components/Renderers/TaskRenderer.php @@ -30,7 +30,7 @@ public function render(Terminal $terminal, ComponentState $state, float $started $hint = match ($this->state) { ComponentState::ERROR => 'An error occurred.', ComponentState::CANCELLED => 'Cancelled.', - ComponentState::DONE => $finishedAt + ComponentState::SUBMITTED => $finishedAt ? 'Done in '.$runtime($finishedAt).'ms.' : 'Done.', default => $hint ?? $runtime(hrtime(as_number: true)) . 'ms', @@ -39,7 +39,7 @@ public function render(Terminal $terminal, ComponentState $state, float $started $this->line( append: str() ->append(match ($this->state) { - ComponentState::DONE => '✔', + ComponentState::SUBMITTED => '✔', ComponentState::ERROR => '✖', ComponentState::CANCELLED => '⚠', default => ''.$this->spinner->render($terminal, $this->state).'', diff --git a/src/Tempest/Console/src/Console.php b/src/Tempest/Console/src/Console.php index a2124e59a..eb20d4422 100644 --- a/src/Tempest/Console/src/Console.php +++ b/src/Tempest/Console/src/Console.php @@ -61,8 +61,6 @@ public function search(string $label, Closure $search, bool $multiple = false, n public function task(string $label, null|Process|Closure $handler): bool; - public function keyValue(string $key, ?string $value = null): void; - 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 ea61b6116..7971e94af 100644 --- a/src/Tempest/Console/src/GenericConsole.php +++ b/src/Tempest/Console/src/GenericConsole.php @@ -18,7 +18,6 @@ use Tempest\Console\Components\Interactive\TaskComponent; use Tempest\Console\Components\Interactive\TextInputComponent; use Tempest\Console\Components\InteractiveComponentRenderer; -use Tempest\Console\Components\Renderers\KeyValueRenderer; use Tempest\Console\Exceptions\UnsupportedComponent; use Tempest\Console\Highlight\TempestConsoleLanguage\TempestConsoleLanguage; use Tempest\Console\Input\ConsoleArgumentBag; @@ -282,11 +281,6 @@ public function task(string $label, null|Process|Closure $handler = null): bool return $this->component(new TaskComponent($label, $handler)); } - public function keyValue(string $key, ?string $value = null): void - { - $this->writeln((new KeyValueRenderer())->render($key, $value)); - } - 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/Renderers/KeyValueRendererTest.php b/tests/Integration/Console/Renderers/KeyValueRendererTest.php deleted file mode 100644 index af03f6e26..000000000 --- a/tests/Integration/Console/Renderers/KeyValueRendererTest.php +++ /dev/null @@ -1,30 +0,0 @@ -render('Foo', 'bar'); - - $this->assertSame('Foo ..................................................................................................................... bar', $rendered); - } - - public function test_render_total_width_smaller_than_text(): void - { - $renderer = new KeyValueRenderer(); - $rendered = $renderer->render('Some long text', 'Some long text', maximumWidth: 0); - - $this->assertSame('Some long text ... Some long text', $rendered); - } -} From d08549f35144464a5b5065f337ecf1bcdd56a8b5 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 16 Dec 2024 22:26:14 +0100 Subject: [PATCH 09/11] style: apply fixes from qa --- .../src/Components/Interactive/TaskComponent.php | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/Tempest/Console/src/Components/Interactive/TaskComponent.php b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php index 0342e2b16..3b931c0cb 100644 --- a/src/Tempest/Console/src/Components/Interactive/TaskComponent.php +++ b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php @@ -11,7 +11,6 @@ use Tempest\Console\Components\ComponentState; use Tempest\Console\Components\Concerns\HasErrors; use Tempest\Console\Components\Concerns\HasState; -use Tempest\Console\Components\Renderers\KeyValueRenderer; use Tempest\Console\Components\Renderers\SpinnerRenderer; use Tempest\Console\Components\Renderers\TaskRenderer; use Tempest\Console\InteractiveConsoleComponent; @@ -24,8 +23,6 @@ final class TaskComponent implements InteractiveConsoleComponent use HasErrors; use HasState; - private KeyValueRenderer $keyValue; - private TaskRenderer $renderer; private int $processId; @@ -41,13 +38,10 @@ final class TaskComponent implements InteractiveConsoleComponent private(set) array $extensions = ['pcntl']; public function __construct( - private readonly string $label, + readonly string $label, private null|Process|Closure $handler = null, - private readonly ?string $success = null, - private readonly ?string $failure = null, ) { $this->handler = $this->resolveHandler($handler); - $this->keyValue = new KeyValueRenderer(); $this->renderer = new TaskRenderer(new SpinnerRenderer(), $label); $this->startedAt = hrtime(as_number: true); } @@ -145,15 +139,15 @@ private function executeHandler(): void $log = function (string ...$lines): void { arr($lines) ->flatMap(fn (string $line) => explode("\n", $line)) - ->each(fn (string $line) => fwrite($this->sockets[0], $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); - } finally { - socket_close($conn); } } From 44efff94edea665046a2a269e9e7794995bd2a0b Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Tue, 24 Dec 2024 12:26:20 +0100 Subject: [PATCH 10/11] refactor: always clean up sockets --- .../Components/Interactive/TaskComponent.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Tempest/Console/src/Components/Interactive/TaskComponent.php b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php index 3b931c0cb..6fc327a94 100644 --- a/src/Tempest/Console/src/Components/Interactive/TaskComponent.php +++ b/src/Tempest/Console/src/Components/Interactive/TaskComponent.php @@ -104,8 +104,10 @@ public function render(Terminal $terminal): Generator } } finally { if ($this->state->isFinished() && $this->processId) { - $this->kill(); + posix_kill($this->processId, SIGTERM); } + + $this->cleanupSockets(); } } @@ -124,14 +126,15 @@ private function renderTask(Terminal $terminal, ?string $line = null): string ); } - private function kill(): void + private function cleanupSockets(): void { - try { - posix_kill($this->processId, SIGTERM); - @fclose($this->sockets[0]); - @fclose($this->sockets[1]); - } catch (Throwable) { + foreach ($this->sockets as $socket) { + if (is_resource($socket)) { + @fclose($socket); + } } + + $this->sockets = []; } private function executeHandler(): void From 53b7b83bbc70996354fa9e9e957df7f27c360a12 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Tue, 24 Dec 2024 12:45:09 +0100 Subject: [PATCH 11/11] test: force usage of sqlite --- tests/Integration/Console/Components/TaskComponentTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Integration/Console/Components/TaskComponentTest.php b/tests/Integration/Console/Components/TaskComponentTest.php index cd72be28b..009f9d51c 100644 --- a/tests/Integration/Console/Components/TaskComponentTest.php +++ b/tests/Integration/Console/Components/TaskComponentTest.php @@ -9,6 +9,8 @@ use Tempest\Console\Components\Interactive\TaskComponent; use Tempest\Console\Console; use Tempest\Console\Terminal\Terminal; +use Tempest\Database\Connections\SQLiteConnection; +use Tempest\Database\DatabaseConfig; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; /** @@ -23,6 +25,10 @@ protected function setUp(): void if (PHP_OS_FAMILY === 'Windows') { $this->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