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
4 changes: 3 additions & 1 deletion src/Cli/BrefSpinner.php
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,9 @@ private function overwrite(string $message): void
$this->output->write(Styles::gray('press [?] for verbose logs'));
}
} else {
$this->output->writeln($message);
// Our colors are raw ANSI escape codes (see `Styles`), which Symfony does not strip on
// its own when the output is not decorated (a pipe, a redirection to a file, CI...).
$this->output->writeln($this->output->isDecorated() ? $message : Styles::strip($message));
}
}

Expand Down
10 changes: 8 additions & 2 deletions src/Cli/IO.php
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,7 @@ private static function writeToLogsFile(string|array $messages): void
if (! self::$logsFileResource) return;

$message = is_array($messages) ? implode(PHP_EOL, $messages) : $messages;
// Strip ANSI
$message = (string) preg_replace('/\x1b\[[0-9;]*m/', '', $message);
$message = Styles::strip($message);
foreach (explode(PHP_EOL, $message) as $line) {
if (empty(trim($line))) continue;
fwrite(self::$logsFileResource, $line . PHP_EOL);
Expand Down Expand Up @@ -216,6 +215,13 @@ private static function safeWrite(string|array $messages): void
$messages = explode(PHP_EOL, $messages);
}

// Our own colors are raw ANSI escape codes (see `Styles`), not Symfony formatter tags, so
// Symfony does not strip them on its own when the output is not decorated (NO_COLOR, a pipe,
// a redirection to a file...). Strip them here so piped/redirected output stays clean.
if (! self::$output->isDecorated()) {
$messages = array_map(Styles::strip(...), $messages);
}

// Symfony `StreamOutput` is not compatible with non-blocking mode
// (in that case `fwrite` returns the number of bytes written, which Symfony ignores)
// Amphp sets STDOUT and STDERR to non-blocking mode, so we need to set them back to blocking mode here.
Expand Down
9 changes: 9 additions & 0 deletions src/Cli/Styles.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,13 @@ public static function gray(string $text): string
{
return "\e[90m{$text}\e[39m";
}

/**
* Removes ANSI escape codes, for when the text ends up somewhere that does not render them
* (a piped/redirected output, a log file...).
*/
public static function strip(string $text): string
{
return (string) preg_replace('/\x1b\[[0-9;]*m/', '', $text);
}
}
106 changes: 106 additions & 0 deletions tests/Cli/IOTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php declare(strict_types=1);

namespace Bref\Cli\Test\Cli;

use Bref\Cli\Cli\IO;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;

/**
* `IO` writes its own decorations (spinner, verbose logs, warnings, errors) using raw ANSI escape
* codes (see `Styles`), not Symfony formatter tags. Symfony only strips formatter tags when the
* output is not decorated, so `IO` has to strip its own raw codes itself, otherwise a piped or
* redirected output (or CI) gets polluted with escape codes.
*/
class IOTest extends TestCase
{
protected function tearDown(): void
{
IO::stop();
}

public function test_warnings_and_errors_have_no_escape_codes_when_not_decorated(): void
{
$output = new BufferedOutput(decorated: false);
IO::init(new ArrayInput([]), $output);

IO::warning('Something to pay attention to');
IO::error(new RuntimeException('The command failed'));

$display = $output->fetch();

$this->assertStringNotContainsString("\e[", $display);
$this->assertStringContainsString('Something to pay attention to', $display);
$this->assertStringContainsString('The command failed', $display);
}

public function test_warnings_and_errors_keep_colors_when_decorated(): void
{
$output = new BufferedOutput(decorated: true);
IO::init(new ArrayInput([]), $output);

IO::warning('Something to pay attention to');

$this->assertStringContainsString("\e[", $output->fetch());
}

public function test_the_spinner_has_no_escape_codes_when_not_decorated(): void
{
$output = new BufferedOutput(decorated: false);
IO::init(new ArrayInput([]), $output);

IO::spin('starting command');
IO::spin('running');
IO::spinClear();

$display = $output->fetch();

$this->assertStringNotContainsString("\e[", $display);
$this->assertStringContainsString('starting command', $display);
$this->assertStringContainsString('running', $display);
}

public function test_the_spinner_keeps_colors_when_decorated(): void
{
$output = new BufferedOutput(decorated: true);
IO::init(new ArrayInput([]), $output);

IO::spin('starting command');
IO::spinClear();

$this->assertStringContainsString("\e[", $output->fetch());
}

/**
* Verbose mode is forced on for non-interactive environments (CI, a piped output...), so its
* lines are exactly the kind of decoration that must stay clean when not decorated.
*/
public function test_verbose_logs_have_no_escape_codes_when_not_decorated(): void
{
$output = new BufferedOutput(decorated: false);
IO::init(new ArrayInput([]), $output);

IO::verbose('Diagnostic detail');

$display = $output->fetch();

$this->assertStringNotContainsString("\e[", $display);
$this->assertStringContainsString('Diagnostic detail', $display);
}

/**
* The command's own output (e.g. what `bref command` prints from the deployed application)
* is never styled by the CLI, and must stay exactly as it is regardless of decoration.
*/
public function test_plain_writeln_is_untouched(): void
{
$output = new BufferedOutput(decorated: false);
IO::init(new ArrayInput([]), $output);

IO::writeln('Hello from the deployed application');

$this->assertSame("Hello from the deployed application\n", $output->fetch());
}
}
22 changes: 22 additions & 0 deletions tests/Cli/StylesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php declare(strict_types=1);

namespace Bref\Cli\Test\Cli;

use Bref\Cli\Cli\Styles;
use PHPUnit\Framework\TestCase;

class StylesTest extends TestCase
{
public function test_strip_removes_ansi_escape_codes(): void
{
$this->assertSame(
'bold red gray plain',
Styles::strip(Styles::bold('bold') . ' ' . Styles::red('red') . ' ' . Styles::gray('gray') . ' plain'),
);
}

public function test_strip_leaves_text_without_escape_codes_untouched(): void
{
$this->assertSame('plain text', Styles::strip('plain text'));
}
}
Loading