From 2f986e3ae04cd9287d335992c9d5b765bda52f44 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 12 Feb 2026 11:21:41 +0100 Subject: [PATCH 1/2] improved --cider mode (thx Claude) --- phpstan-baseline.neon | 12 ++++ src/Framework/Ansi.php | 67 ++++++++++++++++++++ src/Runner/Output/ConsolePrinter.php | 95 +++++++++++++++++++++++++++- src/Runner/OutputHandler.php | 2 + src/Runner/Runner.php | 29 ++++++++- tests/Framework/Ansi.pad.phpt | 35 ++++++++++ tests/Framework/Ansi.truncate.phpt | 35 ++++++++++ 7 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 tests/Framework/Ansi.pad.phpt create mode 100644 tests/Framework/Ansi.truncate.phpt diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6e4c621b..1eaad8f9 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -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 diff --git a/src/Framework/Ansi.php b/src/Framework/Ansi.php index 05684495..bca159d9 100644 --- a/src/Framework/Ansi.php +++ b/src/Framework/Ansi.php @@ -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. */ @@ -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; + } } diff --git a/src/Runner/Output/ConsolePrinter.php b/src/Runner/Output/ConsolePrinter.php index 572dee77..ca47254a 100644 --- a/src/Runner/Output/ConsolePrinter.php +++ b/src/Runner/Output/ConsolePrinter.php @@ -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; @@ -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; @@ -33,6 +37,11 @@ class ConsolePrinter implements Tester\Runner\OutputHandler /** @var array result type => count */ private array $results; private ?string $baseDir; + private int $panelWidth = 60; + private int $panelHeight = 0; + + /** @var \WeakMap */ + private \WeakMap $startTimes; public function __construct( @@ -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; } @@ -53,6 +63,9 @@ 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->threadCount} thread" . ($this->runner->threadCount > 1 ? 's' : '') . "\n\n"); @@ -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), }); @@ -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" @@ -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); + } } diff --git a/src/Runner/OutputHandler.php b/src/Runner/OutputHandler.php index 38905295..749a1feb 100644 --- a/src/Runner/OutputHandler.php +++ b/src/Runner/OutputHandler.php @@ -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 { diff --git a/src/Runner/Runner.php b/src/Runner/Runner.php index b5575d17..9923386f 100644 --- a/src/Runner/Runner.php +++ b/src/Runner/Runner.php @@ -42,6 +42,8 @@ class Runner /** @var array test signature => result (Test::Prepared|Passed|Failed|Skipped) */ private array $lastResults = []; + private int $jobCount = 0; + private int $finishedCount = 0; public function __construct(PhpInterpreter $interpreter) @@ -93,6 +95,8 @@ public function run(): bool foreach ($this->paths as $path) { $this->findTests($path); } + $this->finishedCount = 0; + $this->jobCount = count($this->jobs); if ($this->tempDir) { usort( @@ -102,7 +106,6 @@ public function run(): bool } $threads = range(1, $this->threadCount); - $async = $this->threadCount > 1 && count($this->jobs) > 1; try { @@ -110,10 +113,21 @@ public function run(): bool while ($threads && $this->jobs) { $running[] = $job = array_shift($this->jobs); $job->setEnvironmentVariable(Environment::VariableThread, (string) array_shift($threads)); + foreach ($this->outputHandlers as $handler) { + if (method_exists($handler, 'jobStarted')) { + $handler->jobStarted($job); + } + } $job->run(async: $async); } if ($async) { + foreach ($this->outputHandlers as $handler) { + if (method_exists($handler, 'tick')) { + $handler->tick($running); + } + } + Job::waitForActivity($running); } @@ -124,6 +138,7 @@ public function run(): bool if (!$job->isRunning()) { $threads[] = $job->getEnvironmentVariable(Environment::VariableThread); + $this->finishedCount++; $this->testHandler->assess($job); unset($running[$key]); } @@ -214,6 +229,18 @@ public function getInterpreter(): PhpInterpreter } + public function getJobCount(): int + { + return $this->jobCount; + } + + + public function getFinishedCount(): int + { + return $this->finishedCount; + } + + private function getLastResult(Test $test): int { $signature = $test->getSignature(); diff --git a/tests/Framework/Ansi.pad.phpt b/tests/Framework/Ansi.pad.phpt new file mode 100644 index 00000000..47a2d551 --- /dev/null +++ b/tests/Framework/Ansi.pad.phpt @@ -0,0 +1,35 @@ + Date: Fri, 27 Mar 2026 09:33:56 +0100 Subject: [PATCH 2/2] PhpInterpreter: pass command to proc_open() as array - PHP escapes arguments itself - subshell call is bypassed on Linux too --- src/Runner/CliTester.php | 2 +- src/Runner/Job.php | 2 +- src/Runner/Output/ConsolePrinter.php | 2 +- src/Runner/Output/Logger.php | 2 +- src/Runner/PhpInterpreter.php | 38 +++++++++++++++------------- tests/Runner/PhpInterpreter.phpt | 2 +- 6 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/Runner/CliTester.php b/src/Runner/CliTester.php index b51de53e..d923d101 100644 --- a/src/Runner/CliTester.php +++ b/src/Runner/CliTester.php @@ -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'], ''); diff --git a/src/Runner/Job.php b/src/Runner/Job.php index 135e5695..e7379a4d 100644 --- a/src/Runner/Job.php +++ b/src/Runner/Job.php @@ -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'], diff --git a/src/Runner/Output/ConsolePrinter.php b/src/Runner/Output/ConsolePrinter.php index ca47254a..311bf7ae 100644 --- a/src/Runner/Output/ConsolePrinter.php +++ b/src/Runner/Output/ConsolePrinter.php @@ -67,7 +67,7 @@ public function begin(): void $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"); } diff --git a/src/Runner/Output/Logger.php b/src/Runner/Output/Logger.php index a41fbdbc..cab95e8a 100644 --- a/src/Runner/Output/Logger.php +++ b/src/Runner/Output/Logger.php @@ -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"); } diff --git a/src/Runner/PhpInterpreter.php b/src/Runner/PhpInterpreter.php index 908b1614..b31b9b30 100644 --- a/src/Runner/PhpInterpreter.php +++ b/src/Runner/PhpInterpreter.php @@ -16,7 +16,7 @@ */ class PhpInterpreter { - private string $commandLine; + private array $commandLine; private bool $cgi; private \stdClass $info; private string $error; @@ -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."); @@ -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]); @@ -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; } @@ -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; diff --git a/tests/Runner/PhpInterpreter.phpt b/tests/Runner/PhpInterpreter.phpt index 7715a776..19f9b1f9 100644 --- a/tests/Runner/PhpInterpreter.phpt +++ b/tests/Runner/PhpInterpreter.phpt @@ -35,6 +35,6 @@ Assert::count($count, $engines); // createInterpreter() uses same php.ini as parent if (!$interpreter->isCgi()) { - $output = shell_exec($interpreter->withArguments(['-r echo php_ini_loaded_file();'])->getCommandLine()); + $output = shell_exec($interpreter->withArguments(['-r', 'echo php_ini_loaded_file();'])->getCommandLineStr()); Assert::same(php_ini_loaded_file(), $output); }