From 13da431e685e09d0ff69c0462e4ea10a4462d6a3 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 13 Oct 2026 09:05:55 -0400 Subject: [PATCH] Render Live and TaskProgress live inside a command Command::run() hands handle() an OutputStyle, which is not a ConsoleOutputInterface, so both widgets always took the static path. Resolve the console through a new LiveOutput helper that unwraps the style via ErrorOutput::unwrap(), the one shared implementation, and also requires a decorated output so --no-ansi gets no cursor codes. --- CHANGELOG.md | 19 ++ src/Tools/Support/ErrorOutput.php | 12 +- src/Tools/Support/Live.php | 17 +- src/Tools/Support/LiveOutput.php | 32 +++ .../Widgets/TaskProgress/TaskProgress.php | 10 +- .../Unit/Commands/LiveRenderRoutingTest.php | 214 ++++++++++++++++++ 6 files changed, 295 insertions(+), 9 deletions(-) create mode 100644 src/Tools/Support/LiveOutput.php create mode 100644 tests/Tools/Unit/Commands/LiveRenderRoutingTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 463d012..3a1c8f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to `laranail/console` are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **`Live` and `TaskProgress` never rendered live inside a command.** Both tested + `$output instanceof ConsoleOutputInterface`, and `Illuminate\Console\Command::run()` hands + `handle()` an `OutputStyle`, which is not one, so a command's spinners, animated bars and task + trees always took the static path even on a terminal. They now resolve the console through the + new internal `Tools\Support\LiveOutput`, which unwraps the style with + `ErrorOutput::unwrap()` (the one implementation of the unwrap, now shared with + `ErrorOutput::of()`). + +### Changed + +- **Live rendering now also requires a decorated output.** An undecorated console output + (`--no-ansi`, `NO_COLOR` on a TTY) takes the static path instead of receiving cursor-control + sequences, the same rule Symfony's `ProgressBar` applies before overwriting. A TTY is still + required, as before, through `Capabilities::isInteractive()`. + ## [0.1.6] - 2026-10-12 ### Fixed diff --git a/src/Tools/Support/ErrorOutput.php b/src/Tools/Support/ErrorOutput.php index 778138f..776c9b2 100644 --- a/src/Tools/Support/ErrorOutput.php +++ b/src/Tools/Support/ErrorOutput.php @@ -26,8 +26,18 @@ final class ErrorOutput { public static function of(OutputInterface $output): OutputInterface { - $inner = $output instanceof OutputStyle ? $output->getOutput() : $output; + $inner = self::unwrap($output); return $inner instanceof ConsoleOutputInterface ? $inner->getErrorOutput() : $output; } + + /** + * The output a command's {@see OutputStyle} wraps, or the output itself when it is not one. + * + * The one implementation of the unwrap; {@see LiveOutput} uses it too. + */ + public static function unwrap(OutputInterface $output): OutputInterface + { + return $output instanceof OutputStyle ? $output->getOutput() : $output; + } } diff --git a/src/Tools/Support/Live.php b/src/Tools/Support/Live.php index 9659a6d..f89b05e 100644 --- a/src/Tools/Support/Live.php +++ b/src/Tools/Support/Live.php @@ -45,8 +45,10 @@ public function draw(Renderable|Stringable|string $content): self { $text = implode("\n", $this->lines($content)); - if ($this->capabilities->isInteractive() && $this->output instanceof ConsoleOutputInterface) { - $this->section ??= $this->output->section(); + $console = $this->liveOutput(); + + if ($console instanceof ConsoleOutputInterface) { + $this->section ??= $console->section(); $this->section->overwrite($text); } else { $this->output->writeln($text); @@ -101,7 +103,16 @@ public function animate(array $frames, int $loops = 1, int $intervalMs = 80): se private function canAnimate(): bool { - return $this->capabilities->isInteractive() && $this->output instanceof ConsoleOutputInterface; + return $this->liveOutput() instanceof ConsoleOutputInterface; + } + + /** + * The console to redraw on, or null for a static render: a TTY, and a decorated console + * output seen through the `OutputStyle` a command hands out ({@see LiveOutput}). + */ + private function liveOutput(): ?ConsoleOutputInterface + { + return $this->capabilities->isInteractive() ? LiveOutput::of($this->output) : null; } /** diff --git a/src/Tools/Support/LiveOutput.php b/src/Tools/Support/LiveOutput.php new file mode 100644 index 0000000..178425a --- /dev/null +++ b/src/Tools/Support/LiveOutput.php @@ -0,0 +1,32 @@ +isDecorated() ? $inner : null; + } +} diff --git a/src/Tools/Widgets/TaskProgress/TaskProgress.php b/src/Tools/Widgets/TaskProgress/TaskProgress.php index 7e49488..fbb9f0d 100644 --- a/src/Tools/Widgets/TaskProgress/TaskProgress.php +++ b/src/Tools/Widgets/TaskProgress/TaskProgress.php @@ -7,6 +7,7 @@ use Simtabi\Laranail\Console\Tools\Support\Lang; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; +use Simtabi\Laranail\Console\Tools\Support\LiveOutput; use Simtabi\Laranail\Console\Tools\Support\TimeFormat; use Simtabi\Laranail\Console\Tools\Support\Capabilities; use Simtabi\Laranail\Console\Tools\Support\DisplayWidth; @@ -27,8 +28,6 @@ final class TaskProgress private readonly Capabilities $capabilities; - private readonly bool $interactive; - private ?ConsoleSectionOutput $section = null; /** @var array last status emitted on a non-TTY, per task */ @@ -38,10 +37,11 @@ public function __construct(?OutputInterface $output = null, ?Capabilities $capa { $this->output = $output ?? new ConsoleOutput; $this->capabilities = $capabilities ?? Capabilities::detect(); - $this->interactive = $this->capabilities->isInteractive() && $this->output instanceof ConsoleOutputInterface; + // LiveOutput sees through the OutputStyle a command hands out, and requires decoration. + $console = $this->capabilities->isInteractive() ? LiveOutput::of($this->output) : null; - if ($this->interactive && $this->output instanceof ConsoleOutputInterface) { - $this->section = $this->output->section(); + if ($console instanceof ConsoleOutputInterface) { + $this->section = $console->section(); } } diff --git a/tests/Tools/Unit/Commands/LiveRenderRoutingTest.php b/tests/Tools/Unit/Commands/LiveRenderRoutingTest.php new file mode 100644 index 0000000..f7b3524 --- /dev/null +++ b/tests/Tools/Unit/Commands/LiveRenderRoutingTest.php @@ -0,0 +1,214 @@ + */ + private array $sections = []; + + public function __construct(bool $decorated) + { + $stream = fopen('php://memory', 'w+b'); + + if ($stream === false) { + throw new LogicException('Cannot open php://memory.'); + } + + parent::__construct($stream, decorated: $decorated); + } + + public function getErrorOutput(): OutputInterface + { + return $this; + } + + public function setErrorOutput(OutputInterface $error): void + { + throw new LogicException('The error stream is not used by these tests.'); + } + + public function section(): ConsoleSectionOutput + { + $this->sectionsOpened++; + + return new ConsoleSectionOutput($this->getStream(), $this->sections, $this->getVerbosity(), $this->isDecorated(), $this->getFormatter()); + } + + public function contents(): string + { + $stream = $this->getStream(); + rewind($stream); + + return (string) stream_get_contents($stream); + } +} + +/** + * Animates three frames through the command's own output, which `run()` has wrapped. + */ +final class LiveFramesCommand extends IlluminateCommand +{ + use SupportsNamespacedNames; + + protected $signature = 'laranail::console-test.live-frames'; + + protected $description = 'Live render routing test command'; + + public function handle(): int + { + Live::make($this->output)->refresh(static fn (int $i): string => "frame {$i}", 3, 0); + + return self::SUCCESS; + } +} + +/** + * Drives a TaskProgress through the command's own output, which `run()` has wrapped. + */ +final class TaskProgressFramesCommand extends IlluminateCommand +{ + use SupportsNamespacedNames; + + protected $signature = 'laranail::console-test.task-progress'; + + protected $description = 'TaskProgress render routing test command'; + + public function handle(): int + { + $progress = TaskProgress::make($this->output); + $task = $progress->task('Compile', 2); + $task->start(); + $progress->draw(); + $task->advance(2)->succeed(); + $progress->draw(); + + return self::SUCCESS; + } +} + +/** + * Live rendering through the real `Illuminate\Console\Command::run()` path. + * + * `run()` wraps the output it is given in an `OutputStyle` before `handle()` sees it, and + * `OutputStyle` is not a `ConsoleOutputInterface`. These tests hand a command a decorated console + * output and run it the way `php artisan` does, so a bare `instanceof` check on the wrapper fails + * them; a bare Symfony output would skip the wrapper and pass on the defect. + */ +final class LiveRenderRoutingTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + + $kernel = $this->app->make(Kernel::class); + $kernel->registerCommand(new LiveFramesCommand); + $kernel->registerCommand(new TaskProgressFramesCommand); + } + + protected function tearDown(): void + { + Capabilities::clearFake(); + parent::tearDown(); + } + + public function test_live_redraws_in_place_inside_a_command_on_a_decorated_terminal(): void + { + Capabilities::fake(colors: false, unicode: false, interactive: true); + $output = new SectionedStreamOutput(decorated: true); + + self::assertSame(0, $this->runCommand('laranail::console-test.live-frames', $output)); + + self::assertSame(1, $output->sectionsOpened); + self::assertStringContainsString('frame 0', $output->contents()); + self::assertStringContainsString('frame 2', $output->contents()); + self::assertStringContainsString("\033[", $output->contents()); + } + + public function test_live_falls_back_to_one_static_frame_on_an_undecorated_terminal(): void + { + Capabilities::fake(colors: false, unicode: false, interactive: true); + $output = new SectionedStreamOutput(decorated: false); + + self::assertSame(0, $this->runCommand('laranail::console-test.live-frames', $output)); + + self::assertSame(0, $output->sectionsOpened); + self::assertSame('frame 2' . PHP_EOL, $output->contents()); + } + + public function test_live_falls_back_to_one_static_frame_without_a_tty(): void + { + Capabilities::fake(colors: false, unicode: false, interactive: false); + $output = new SectionedStreamOutput(decorated: true); + + self::assertSame(0, $this->runCommand('laranail::console-test.live-frames', $output)); + + self::assertSame(0, $output->sectionsOpened); + self::assertSame('frame 2' . PHP_EOL, $output->contents()); + } + + public function test_task_progress_redraws_in_place_inside_a_command_on_a_decorated_terminal(): void + { + Capabilities::fake(colors: false, unicode: false, interactive: true); + $output = new SectionedStreamOutput(decorated: true); + + self::assertSame(0, $this->runCommand('laranail::console-test.task-progress', $output)); + + self::assertSame(1, $output->sectionsOpened); + self::assertStringContainsString("\033[", $output->contents()); + } + + public function test_task_progress_emits_one_line_per_state_change_on_an_undecorated_terminal(): void + { + Capabilities::fake(colors: false, unicode: false, interactive: true); + $output = new SectionedStreamOutput(decorated: false); + + self::assertSame(0, $this->runCommand('laranail::console-test.task-progress', $output)); + + self::assertSame(0, $output->sectionsOpened); + self::assertStringNotContainsString("\033[", $output->contents()); + self::assertCount(2, array_filter(explode(PHP_EOL, $output->contents()))); + } + + public function test_task_progress_emits_one_line_per_state_change_without_a_tty(): void + { + Capabilities::fake(colors: false, unicode: false, interactive: false); + $output = new SectionedStreamOutput(decorated: true); + + self::assertSame(0, $this->runCommand('laranail::console-test.task-progress', $output)); + + self::assertSame(0, $output->sectionsOpened); + self::assertCount(2, array_filter(explode(PHP_EOL, $output->contents()))); + } + + private function runCommand(string $name, SectionedStreamOutput $output): int + { + $command = Artisan::all()[$name]; + self::assertInstanceOf(IlluminateCommand::class, $command); + + return $command->run(new ArrayInput(['command' => $name]), $output); + } +}