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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/Tools/Support/ErrorOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
17 changes: 14 additions & 3 deletions src/Tools/Support/Live.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

/**
Expand Down
32 changes: 32 additions & 0 deletions src/Tools/Support/LiveOutput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\Console\Tools\Support;

use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Output\ConsoleOutputInterface;

/**
* Resolves the console an output can redraw in place on, seeing through Laravel's command wrapper.
*
* `Illuminate\Console\Command::run()` wraps the terminal's `ConsoleOutput` in an `OutputStyle`
* before `handle()` runs, and `OutputStyle` is not a {@see ConsoleOutputInterface}, so a bare
* `instanceof` check sends every command's live rendering down the static path. This unwraps the
* style through {@see ErrorOutput::unwrap()} and then requires a decorated output: an undecorated
* one (`--no-ansi`, a pipe) must not receive cursor-control sequences, which is the same rule
* Symfony's own `ProgressBar` applies before it overwrites.
*
* The TTY check stays with the caller, in {@see Capabilities::isInteractive()}.
*
* @internal
*/
final class LiveOutput
{
public static function of(OutputInterface $output): ?ConsoleOutputInterface
{
$inner = ErrorOutput::unwrap($output);

return $inner instanceof ConsoleOutputInterface && $inner->isDecorated() ? $inner : null;
}
}
10 changes: 5 additions & 5 deletions src/Tools/Widgets/TaskProgress/TaskProgress.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,8 +28,6 @@ final class TaskProgress

private readonly Capabilities $capabilities;

private readonly bool $interactive;

private ?ConsoleSectionOutput $section = null;

/** @var array<int, TaskStatus> last status emitted on a non-TTY, per task */
Expand All @@ -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();
}
}

Expand Down
214 changes: 214 additions & 0 deletions tests/Tools/Unit/Commands/LiveRenderRoutingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\Console\Tools\Tests\Unit\Commands;

use LogicException;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Contracts\Console\Kernel;
use Symfony\Component\Console\Input\ArrayInput;
use Simtabi\Laranail\Console\Tools\Support\Live;
use Simtabi\Laranail\Console\Tools\Tests\TestCase;
use Symfony\Component\Console\Output\StreamOutput;
use Illuminate\Console\Command as IlluminateCommand;
use Symfony\Component\Console\Output\OutputInterface;
use Simtabi\Laranail\Console\Tools\Support\Capabilities;
use Symfony\Component\Console\Output\ConsoleSectionOutput;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Simtabi\Laranail\Console\Tools\Widgets\TaskProgress\TaskProgress;
use Simtabi\Laranail\Console\Tools\Commands\Concerns\SupportsNamespacedNames;

/**
* A terminal stand-in that reports as a `ConsoleOutputInterface`, as Symfony's `ConsoleOutput`
* does, and hands out real sections over one in-memory stream so a test can read what was drawn.
*/
final class SectionedStreamOutput extends StreamOutput implements ConsoleOutputInterface
{
public int $sectionsOpened = 0;

/** @var list<ConsoleSectionOutput> */
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);
}
}
Loading