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
12 changes: 12 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ parameters:
count: 1
path: src/Runner/CommandLine.php

-
message: '#^Call to function method_exists\(\) with Tester\\Runner\\OutputHandler and ''jobStarted'' will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Runner/Runner.php

-
message: '#^Call to function method_exists\(\) with Tester\\Runner\\OutputHandler and ''tick'' will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: src/Runner/Runner.php

-
message: '#^If condition is always false\.$#'
identifier: if.alwaysFalse
Expand Down
67 changes: 67 additions & 0 deletions src/Framework/Ansi.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,23 @@ public static function hideCursor(): string
}


/**
* Returns ANSI sequence to move the cursor by the given number of columns (x) and rows (y).
*/
public static function cursorMove(int $x = 0, int $y = 0): string
{
return match (true) {
$x < 0 => "\e[" . (-$x) . 'D',
$x > 0 => "\e[{$x}C",
default => '',
} . match (true) {
$y < 0 => "\e[" . (-$y) . 'A',
$y > 0 => "\e[{$y}B",
default => '',
};
}


/**
* Returns ANSI sequence to clear from cursor to end of line.
*/
Expand Down Expand Up @@ -114,7 +131,57 @@ public static function reset(): string
*/
public static function textWidth(string $text): int
{
$text = self::stripAnsi($text);
return preg_match_all('/./su', $text)
+ preg_match_all('/[\x{1F300}-\x{1F9FF}]/u', $text); // emoji are 2-wide
}


/**
* Pads text to specified display width.
* @param STR_PAD_LEFT|STR_PAD_RIGHT|STR_PAD_BOTH $type
*/
public static function pad(
string $text,
int $width,
string $char = ' ',
int $type = STR_PAD_RIGHT,
): string
{
$padding = $width - self::textWidth($text);
if ($padding <= 0) {
return $text;
}

return match ($type) {
STR_PAD_LEFT => str_repeat($char, $padding) . $text,
STR_PAD_RIGHT => $text . str_repeat($char, $padding),
STR_PAD_BOTH => str_repeat($char, intdiv($padding, 2)) . $text . str_repeat($char, $padding - intdiv($padding, 2)),
};
}


/**
* Truncates text to max display width, adding ellipsis if needed.
*/
public static function truncate(string $text, int $maxWidth, string $ellipsis = '…'): string
{
if (self::textWidth($text) <= $maxWidth) {
return $text;
}

$maxWidth -= self::textWidth($ellipsis);
$res = '';
$width = 0;
foreach (preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY) as $char) {
$charWidth = preg_match('/[\x{1F300}-\x{1F9FF}]/u', $char) ? 2 : 1;
if ($width + $charWidth > $maxWidth) {
break;
}
$res .= $char;
$width += $charWidth;
}

return $res . $ellipsis;
}
}
2 changes: 1 addition & 1 deletion src/Runner/CliTester.php
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ private function prepareCodeCoverage(Runner $runner): string
{
$engines = $this->interpreter->getCodeCoverageEngines();
if (count($engines) < 1) {
throw new \Exception("Code coverage functionality requires Xdebug or PCOV extension or PHPDBG SAPI (used {$this->interpreter->getCommandLine()})");
throw new \Exception("Code coverage functionality requires Xdebug or PCOV extension or PHPDBG SAPI (used {$this->interpreter->getCommandLineStr()})");
}

file_put_contents($this->options['--coverage'], '');
Expand Down
2 changes: 1 addition & 1 deletion src/Runner/Job.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public function run(bool $async = false): void
$this->duration = -microtime(as_float: true);
$this->proc = proc_open(
$this->interpreter
->withArguments(['-d register_argc_argv=on', $this->test->getFile(), ...$args])
->withArguments(['-d', 'register_argc_argv=on', $this->test->getFile(), ...$args])
->getCommandLine(),
[
['pipe', 'r'],
Expand Down
97 changes: 94 additions & 3 deletions src/Runner/Output/ConsolePrinter.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@

use Tester;
use Tester\Ansi;
use Tester\Environment;
use Tester\Runner\Job;
use Tester\Runner\Runner;
use Tester\Runner\Test;
use function sprintf, strlen;
use function count, fwrite, sprintf, str_repeat, strlen;
use const DIRECTORY_SEPARATOR;


Expand All @@ -24,6 +26,8 @@ class ConsolePrinter implements Tester\Runner\OutputHandler
public const ModeCider = 2;
public const ModeLines = 3;

private const MaxDisplayedThreads = 20;

/** @var resource */
private $file;
private string $buffer;
Expand All @@ -33,6 +37,11 @@ class ConsolePrinter implements Tester\Runner\OutputHandler
/** @var array<Test::Passed|Test::Skipped|Test::Failed, int> result type => count */
private array $results;
private ?string $baseDir;
private int $panelWidth = 60;
private int $panelHeight = 0;

/** @var \WeakMap<Job, float> */
private \WeakMap $startTimes;


public function __construct(
Expand All @@ -43,6 +52,7 @@ public function __construct(
private int $mode = self::ModeDots,
) {
$this->file = fopen($file ?? 'php://output', 'w') ?: throw new \RuntimeException("Cannot open file '$file' for writing.");
$this->startTimes = new \WeakMap;
}


Expand All @@ -53,8 +63,11 @@ public function begin(): void
$this->baseDir = null;
$this->results = [Test::Passed => 0, Test::Skipped => 0, Test::Failed => 0];
$this->time = -microtime(as_float: true);
if ($this->mode === self::ModeCider && $this->runner->threadCount < 2) {
$this->mode = self::ModeLines;
}
fwrite($this->file, $this->runner->getInterpreter()->getShortInfo()
. ' | ' . $this->runner->getInterpreter()->getCommandLine()
. ' | ' . $this->runner->getInterpreter()->getCommandLineStr()
. " | {$this->runner->threadCount} thread" . ($this->runner->threadCount > 1 ? 's' : '') . "\n\n");
}

Expand Down Expand Up @@ -89,7 +102,7 @@ public function finish(Test $test): void
$this->results[$result]++;
fwrite($this->file, match ($this->mode) {
self::ModeDots => [Test::Passed => '.', Test::Skipped => 's', Test::Failed => Ansi::colorize('F', 'white/red')][$result],
self::ModeCider => [Test::Passed => '🍏', Test::Skipped => 's', Test::Failed => '🍎'][$result],
self::ModeCider => '',
self::ModeLines => $this->generateFinishLine($test),
});

Expand All @@ -106,6 +119,12 @@ public function finish(Test $test): void

public function end(): void
{
if ($this->panelHeight) {
fwrite($this->file, Ansi::cursorMove(y: -$this->panelHeight)
. str_repeat(Ansi::clearLine() . "\n", $this->panelHeight)
. Ansi::cursorMove(y: -$this->panelHeight));
}

$run = array_sum($this->results);
fwrite($this->file, !$this->count ? "No tests found\n" :
"\n\n" . $this->buffer . "\n"
Expand Down Expand Up @@ -158,4 +177,76 @@ private function generateFinishLine(Test $test): string
$message,
);
}


public function jobStarted(Job $job): void
{
$this->startTimes[$job] = microtime(true);
}


/**
* @param Job[] $running
*/
public function tick(array $running): void
{
if ($this->mode !== self::ModeCider) {
return;
}

// Move cursor up to overwrite previous output
if ($this->panelHeight) {
fwrite($this->file, Ansi::cursorMove(y: -$this->panelHeight));
}

$lines = [];

// Header with progress bar
$barWidth = $this->panelWidth - 12;
$filled = (int) round($barWidth * ($this->runner->getFinishedCount() / $this->runner->getJobCount()));
$lines[] = '╭' . Ansi::pad(' ' . str_repeat('█', $filled) . str_repeat('░', $barWidth - $filled) . ' ', $this->panelWidth - 2, '─', STR_PAD_BOTH) . '╮';

$threadJobs = [];
foreach ($running as $job) {
$threadJobs[(int) $job->getEnvironmentVariable(Environment::VariableThread)] = $job;
}

// Thread lines
$numWidth = strlen((string) $this->runner->threadCount);
$displayCount = min($this->runner->threadCount, self::MaxDisplayedThreads);

for ($t = 1; $t <= $displayCount; $t++) {
if (isset($threadJobs[$t])) {
$job = $threadJobs[$t];
$name = basename($job->getTest()->getFile());
$time = sprintf('%0.1fs', microtime(true) - ($this->startTimes[$job] ?? microtime(true)));
$nameWidth = $this->panelWidth - $numWidth - strlen($time) - 7;
$name = Ansi::pad(Ansi::truncate($name, $nameWidth), $nameWidth);
$line = Ansi::colorize(sprintf("%{$numWidth}d:", $t), 'lime') . " $name " . Ansi::colorize($time, 'yellow');
} else {
$line = Ansi::pad(Ansi::colorize(sprintf("%{$numWidth}d: -", $t), 'gray'), $this->panelWidth - 4);
}
$lines[] = '│ ' . $line . ' │';
}

if ($this->runner->threadCount > self::MaxDisplayedThreads) {
$more = $this->runner->threadCount - self::MaxDisplayedThreads;
$ellipsis = Ansi::colorize("… and $more more", 'gray');
$lines[] = '│' . Ansi::pad($ellipsis, $this->panelWidth - 2) . '│';
}

// Footer: (85 tests, 🍏×74 🍎×2, 9.0s)
$summary = "($this->count tests, "
. ($this->results[Test::Passed] ? "🍏×{$this->results[Test::Passed]}" : '')
. ($this->results[Test::Failed] ? " 🍎×{$this->results[Test::Failed]}" : '')
. ', ' . sprintf('%0.1fs', $this->time + microtime(true)) . ')';
$lines[] = '╰' . Ansi::pad($summary, $this->panelWidth - 2, '─', STR_PAD_BOTH) . '╯';

foreach ($lines as $line) {
fwrite($this->file, "\r" . $line . Ansi::clearLine() . "\n");
}
fflush($this->file);

$this->panelHeight = count($lines);
}
}
2 changes: 1 addition & 1 deletion src/Runner/Output/Logger.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function begin(): void
$this->count = 0;
$this->results = [Test::Passed => 0, Test::Skipped => 0, Test::Failed => 0];
fwrite($this->file, 'PHP ' . $this->runner->getInterpreter()->getVersion()
. ' | ' . $this->runner->getInterpreter()->getCommandLine()
. ' | ' . $this->runner->getInterpreter()->getCommandLineStr()
. " | {$this->runner->threadCount} threads\n\n");
}

Expand Down
2 changes: 2 additions & 0 deletions src/Runner/OutputHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

/**
* Receives test lifecycle events from the runner to produce output.
* @method void jobStarted(Job $job) called when a job starts running
* @method void tick(Job[] $running) called periodically during test execution
*/
interface OutputHandler
{
Expand Down
38 changes: 21 additions & 17 deletions src/Runner/PhpInterpreter.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
*/
class PhpInterpreter
{
private string $commandLine;
private array $commandLine;
private bool $cgi;
private \stdClass $info;
private string $error;
Expand All @@ -25,14 +25,10 @@ class PhpInterpreter
/** @param string[] $args */
public function __construct(string $path, array $args = [])
{
$this->commandLine = Helpers::escapeArg($path);
$proc = @proc_open( // @ is escalated to exception
$this->commandLine . ' --version',
[$path, '--version'],
[['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
$pipes,
null,
null,
['bypass_shell' => true],
);
if ($proc === false) {
throw new \Exception("Cannot run PHP interpreter $path. Use -p option.");
Expand All @@ -42,20 +38,22 @@ public function __construct(string $path, array $args = [])
$output = stream_get_contents($pipes[1]);
proc_close($proc);

$args = ' ' . implode(' ', array_map([Helpers::class, 'escapeArg'], $args));
$this->commandLine = array_merge([$path], $args);
if (str_contains($output, 'phpdbg')) {
$args = ' -qrrb -S cli' . $args;
array_push($this->commandLine, '-qrrb', '-S', 'cli');
}

$this->commandLine .= rtrim($args);

$proc = proc_open(
$this->commandLine . ' -d register_argc_argv=on ' . Helpers::escapeArg(__DIR__ . '/info.php') . ' serialized',
array_merge(
$this->commandLine,
[
'-d', 'register_argc_argv=on',
__DIR__ . '/info.php',
'serialized',
],
),
[['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
$pipes,
null,
null,
['bypass_shell' => true],
) ?: throw new \Exception("Unable to run $path.");

$output = stream_get_contents($pipes[1]);
Expand Down Expand Up @@ -88,7 +86,7 @@ public function __construct(string $path, array $args = [])
public function withArguments(array $args): static
{
$me = clone $this;
$me->commandLine .= ' ' . implode(' ', array_map([Helpers::class, 'escapeArg'], $args));
array_push($me->commandLine, ...$args);
return $me;
}

Expand All @@ -98,16 +96,22 @@ public function withArguments(array $args): static
*/
public function withPhpIniOption(string $name, ?string $value = null): static
{
return $this->withArguments(['-d ' . $name . ($value === null ? '' : "=$value")]);
return $this->withArguments(['-d', $name . ($value === null ? '' : "=$value")]);
}


public function getCommandLine(): string
public function getCommandLine(): array
{
return $this->commandLine;
}


public function getCommandLineStr(): string
{
return implode(' ', array_map([Helpers::class, 'escapeArg'], $this->commandLine));
}


public function getVersion(): string
{
return $this->info->version;
Expand Down
Loading
Loading