diff --git a/README.md b/README.md index 6df84512..84e6252f 100644 --- a/README.md +++ b/README.md @@ -33,17 +33,14 @@ The php-profiler outputs more accurate line numbers, and additionally can show e ## Requirements ### Supported PHP versions #### Execution -- PHP-8.0 64bit Linux x86_64 (NTS / ZTS) +- PHP 8.0+ (NTS / ZTS) +- 64bit Linux x86_64 - FFI extension must be enabled. - If the target process is ZTS, PCNTL extension must be enabled. #### Target -- PHP-7.0 64bit Linux x86_64 (NTS / ZTS) -- PHP-7.1 64bit Linux x86_64 (NTS / ZTS) -- PHP-7.2 64bit Linux x86_64 (NTS / ZTS) -- PHP-7.3 64bit Linux x86_64 (NTS / ZTS) -- PHP-7.4 64bit Linux x86_64 (NTS / ZTS) -- PHP-8.0 64bit Linux x86_64 (NTS / ZTS) +- PHP 7.0+ (NTS / ZTS) +- 64bit Linux x86_64 On targeting ZTS, the target process must load libpthread.so, and also you must have unstripped binary of the interpreter and the libpthread.so, to find EG from the TLS. @@ -127,6 +124,35 @@ Options: -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug ``` +### top-like mode +```bash +./php-profiler inspector:top --help +Description: + show an aggregated view of traces in real time in a form similar to the UNIX top command. + +Usage: + inspector:top [options] + +Options: + -P, --target-regex=TARGET-REGEX regex to find target processes which have matching command-line (required) + -T, --threads[=THREADS] number of workers (default: 8) + -d, --depth[=DEPTH] max depth + -s, --sleep-ns[=SLEEP-NS] nanoseconds between traces (default: 1000 * 1000 * 10) + -r, --max-retries[=MAX-RETRIES] max retries on contiguous errors of read (default: 10) + -S, --stop-process[=STOP-PROCESS] stop the target process while reading its trace (default: off) + --php-regex[=PHP-REGEX] regex to find the php binary loaded in the target process + --libpthread-regex[=LIBPTHREAD-REGEX] regex to find the libpthread.so loaded in the target process + --php-version[=PHP-VERSION] php version of the target (default: v80) + --php-path[=PHP-PATH] path to the php binary (only needed in tracing chrooted ZTS target) + --libpthread-path[=LIBPTHREAD-PATH] path to the libpthread.so (only needed in tracing chrooted ZTS target) + -h, --help Display help for the given command. When no command is given display help for the list command + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi|--no-ansi Force (or disable --no-ansi) ANSI output + -n, --no-interaction Do not ask any interactive question + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug +``` + ### Get the address of EG ```bash ./php-profiler inspector:eg --help diff --git a/src/Command/Inspector/TopLikeCommand.php b/src/Command/Inspector/TopLikeCommand.php new file mode 100644 index 00000000..c736f4ac --- /dev/null +++ b/src/Command/Inspector/TopLikeCommand.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PhpProfiler\Command\Inspector; + +use Amp\Loop; +use Amp\Promise; +use PhpProfiler\Inspector\Daemon\Dispatcher\DispatchTable; +use PhpProfiler\Inspector\Daemon\Dispatcher\WorkerPool; +use PhpProfiler\Inspector\Daemon\Reader\Context\PhpReaderContextCreator; +use PhpProfiler\Inspector\Daemon\Reader\Protocol\Message\TraceMessage; +use PhpProfiler\Inspector\Daemon\Searcher\Context\PhpSearcherContextCreator; +use PhpProfiler\Inspector\Output\TopLike\TopLikeFormatter; +use PhpProfiler\Inspector\Output\TopLike\TopLikeFormatterFactory; +use PhpProfiler\Inspector\Settings\DaemonSettings\DaemonSettingsFromConsoleInput; +use PhpProfiler\Inspector\Settings\GetTraceSettings\GetTraceSettingsFromConsoleInput; +use PhpProfiler\Inspector\Settings\TargetPhpSettings\TargetPhpSettingsFromConsoleInput; +use PhpProfiler\Inspector\Settings\TraceLoopSettings\TraceLoopSettingsFromConsoleInput; +use PhpProfiler\Lib\Console\EchoBackCanceller; +use PhpProfiler\Lib\Log\Log; +use PhpProfiler\Lib\PhpProcessReader\CallTrace; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +use function Amp\call; + +final class TopLikeCommand extends Command +{ + public function __construct( + private PhpSearcherContextCreator $php_searcher_context_creator, + private PhpReaderContextCreator $php_reader_context_creator, + private DaemonSettingsFromConsoleInput $daemon_settings_from_console_input, + private GetTraceSettingsFromConsoleInput $get_trace_settings_from_console_input, + private TargetPhpSettingsFromConsoleInput $target_php_settings_from_console_input, + private TraceLoopSettingsFromConsoleInput $trace_loop_settings_from_console_input, + private TopLikeFormatterFactory $formatter_factory, + ) { + parent::__construct(); + } + + public function configure(): void + { + $this->setName('inspector:top') + ->setDescription( + 'show an aggregated view of traces in real time in a form similar to the UNIX top command.' + ) + ; + $this->daemon_settings_from_console_input->setOptions($this); + $this->get_trace_settings_from_console_input->setOptions($this); + $this->trace_loop_settings_from_console_input->setOptions($this); + $this->target_php_settings_from_console_input->setOptions($this); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $get_trace_settings = $this->get_trace_settings_from_console_input->createSettings($input); + $daemon_settings = $this->daemon_settings_from_console_input->createSettings($input); + $target_php_settings = $this->target_php_settings_from_console_input->createSettings($input); + $loop_settings = $this->trace_loop_settings_from_console_input->createSettings($input); + $formatter = $this->formatter_factory->create( + $daemon_settings->target_regex, + $output + ); + + $searcher_context = $this->php_searcher_context_creator->create(); + Promise\wait($searcher_context->start()); + Promise\wait($searcher_context->sendTargetRegex($daemon_settings->target_regex)); + + $worker_pool = WorkerPool::create( + $this->php_reader_context_creator, + $daemon_settings->threads, + $target_php_settings, + $loop_settings, + $get_trace_settings + ); + + $dispatch_table = new DispatchTable( + $worker_pool, + ); + + $_echo_back_canceler = new EchoBackCanceller(); + + Loop::onReadable( + STDIN, + /** @param resource $stream */ + function (string $watcher_id, $stream) { + $key = fread($stream, 1); + if ($key === 'q') { + Loop::cancel($watcher_id); + Loop::stop(); + } + } + ); + Loop::run(function () use ($dispatch_table, $searcher_context, $worker_pool, $formatter) { + $promises = []; + $promises[] = call(function () use ($searcher_context, $dispatch_table) { + while (1) { + Log::debug('receiving pid List'); + $update_target_message = yield $searcher_context->receivePidList(); + Log::debug('update targets', [ + 'update' => $update_target_message->target_process_list->getArray(), + 'current' => $dispatch_table->worker_pool->debugDump(), + ]); + $dispatch_table->updateTargets($update_target_message->target_process_list); + Log::debug('target updated', [$dispatch_table->worker_pool->debugDump()]); + } + }); + foreach ($worker_pool->getWorkers() as $reader) { + $promises[] = call( + function () use ($reader, $dispatch_table, $formatter) { + while (1) { + $result = yield $reader->receiveTraceOrDetachWorker(); + if ($result instanceof TraceMessage) { + $this->outputTrace($formatter, $result); + } else { + Log::debug('releaseOne', [$result]); + $dispatch_table->releaseOne($result->pid); + $this->outputTrace($formatter, new TraceMessage( + new CallTrace() + )); + } + } + } + ); + } + yield $promises; + }); + + return 0; + } + + private function outputTrace( + TopLikeFormatter $formatter, + TraceMessage $message + ): void { + $formatter->format($message->trace); + } +} diff --git a/src/Inspector/Output/TopLike/FunctionEntry.php b/src/Inspector/Output/TopLike/FunctionEntry.php new file mode 100644 index 00000000..374ce90c --- /dev/null +++ b/src/Inspector/Output/TopLike/FunctionEntry.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PhpProfiler\Inspector\Output\TopLike; + +final class FunctionEntry +{ + public function __construct( + public string $name, + public string $file, + public int $lineno, + public int $count_exclusive = 0, + public int $count_inclusive = 0, + public int $total_count_exclusive = 0, + public int $total_count_inclusive = 0, + public float $percent_exclusive = 0, + ) { + } +} diff --git a/src/Inspector/Output/TopLike/Outputter.php b/src/Inspector/Output/TopLike/Outputter.php new file mode 100644 index 00000000..c60a3887 --- /dev/null +++ b/src/Inspector/Output/TopLike/Outputter.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PhpProfiler\Inspector\Output\TopLike; + +interface Outputter +{ + public function display(string $trace_target, Stat $stat): void; +} diff --git a/src/Inspector/Output/TopLike/Stat.php b/src/Inspector/Output/TopLike/Stat.php new file mode 100644 index 00000000..fb4b1f78 --- /dev/null +++ b/src/Inspector/Output/TopLike/Stat.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PhpProfiler\Inspector\Output\TopLike; + +use PhpProfiler\Lib\PhpProcessReader\CallFrame; +use PhpProfiler\Lib\PhpProcessReader\CallTrace; + +final class Stat +{ + /** @param array $function_entries */ + public function __construct( + public array $function_entries = [], + public int $sample_count = 0, + public int $total_count = 0, + ) { + } + + public function addTrace(CallTrace $call_trace): void + { + $this->sample_count++; + foreach ($call_trace->call_frames as $frame_number => $call_frame) { + $this->addFrame($call_frame, $frame_number === 0); + } + } + + private function addFrame(CallFrame $call_frame, bool $is_first_frame): void + { + $name = $call_frame->getFullyQualifiedFunctionName(); + if (!isset($this->function_entries[$name])) { + $this->function_entries[$name] = new FunctionEntry( + $name, + $call_frame->file_name, + $call_frame->getLineno(), + ); + } + if ($is_first_frame) { + $this->function_entries[$name]->count_exclusive++; + } + $this->function_entries[$name]->count_inclusive++; + } + + public function updateStat(): void + { + if (count($this->function_entries) === 0) { + return; + } + + $this->calculateEntryTotals(); + $this->sort(); + $this->updateTotalSampleCount(); + } + + + public function sort(): void + { + \uasort($this->function_entries, function (FunctionEntry $a, FunctionEntry $b) { + if ($b->count_exclusive === $a->count_exclusive) { + if ($b->count_inclusive === $a->count_inclusive) { + if ($b->total_count_exclusive === $a->total_count_exclusive) { + return $b->total_count_inclusive <=> $a->total_count_inclusive; + } + return $b->total_count_exclusive <=> $a->total_count_exclusive; + } + return $b->count_inclusive <=> $a->count_inclusive; + } + return $b->count_exclusive <=> $a->count_exclusive; + }); + } + + public function calculateEntryTotals(): void + { + foreach ($this->function_entries as $function_entry) { + $function_entry->total_count_exclusive += $function_entry->count_exclusive; + $function_entry->total_count_inclusive += $function_entry->count_inclusive; + $function_entry->percent_exclusive = + $this->sample_count < 1 + ? 0.0 + : 100.0 * $function_entry->count_exclusive / $this->sample_count + ; + } + } + + public function updateTotalSampleCount(): void + { + $this->total_count += $this->sample_count; + } + + public function clearCurrentSamples(): void + { + $this->sample_count = 0; + foreach ($this->function_entries as $function_entry) { + $function_entry->count_exclusive = 0; + $function_entry->count_inclusive = 0; + } + } +} diff --git a/src/Inspector/Output/TopLike/TopLikeFormatter.php b/src/Inspector/Output/TopLike/TopLikeFormatter.php new file mode 100644 index 00000000..b040717f --- /dev/null +++ b/src/Inspector/Output/TopLike/TopLikeFormatter.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PhpProfiler\Inspector\Output\TopLike; + +use PhpProfiler\Lib\DateTime\Clock; +use PhpProfiler\Lib\PhpProcessReader\CallTrace; + +final class TopLikeFormatter +{ + private Stat $stat; + private \DateTimeImmutable $previous; + + public function __construct( + private string $trace_target, + private Outputter $outputter, + private Clock $clock, + ) { + $this->stat = new Stat(); + $this->previous = $this->clock->now(); + } + + public function format(CallTrace $call_trace): void + { + $this->stat->addTrace($call_trace); + $now = $this->clock->now(); + if ($now >= $this->previous->modify('+1 second')) { + $this->outputter->display($this->trace_target, $this->stat); + $this->previous = $now; + } + } +} diff --git a/src/Inspector/Output/TopLike/TopLikeFormatterFactory.php b/src/Inspector/Output/TopLike/TopLikeFormatterFactory.php new file mode 100644 index 00000000..cf09e0be --- /dev/null +++ b/src/Inspector/Output/TopLike/TopLikeFormatterFactory.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PhpProfiler\Inspector\Output\TopLike; + +use PhpProfiler\Lib\DateTime\OnDemandClock; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Terminal; + +final class TopLikeFormatterFactory +{ + public function __construct( + private Terminal $terminal, + private OnDemandClock $clock, + ) { + } + + public function create( + string $target_regex, + OutputInterface $output + ): TopLikeFormatter { + assert($output instanceof ConsoleOutputInterface); + return new TopLikeFormatter( + $target_regex, + new TopLikeOutputter( + $output, + $this->terminal, + ), + $this->clock + ); + } +} diff --git a/src/Inspector/Output/TopLike/TopLikeOutputter.php b/src/Inspector/Output/TopLike/TopLikeOutputter.php new file mode 100644 index 00000000..466f5dd7 --- /dev/null +++ b/src/Inspector/Output/TopLike/TopLikeOutputter.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PhpProfiler\Inspector\Output\TopLike; + +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Helper\TableCell; +use Symfony\Component\Console\Helper\TableCellStyle; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Terminal; + +final class TopLikeOutputter implements Outputter +{ + public function __construct( + private ConsoleOutputInterface $output, + private Terminal $terminal, + ) { + } + + public function display(string $trace_target, Stat $stat): void + { + $stat->updateStat(); + $this->output($trace_target, $stat); + $stat->clearCurrentSamples(); + } + + private function output(string $trace_target, Stat $stat): void + { + $this->output->write("\e[H\e[2J"); + $this->output->writeln($trace_target); + $this->output->writeln( + sprintf( + 'samp_count=%d func_count=%d total_count=%d', + $stat->sample_count, + count($stat->function_entries), + $stat->total_count + ) + ); + $this->output->writeln(''); + $count = 7; + $width = $this->terminal->getWidth(); + $height = $this->terminal->getHeight(); + $padding_name = max(0, $width - 41); + + $rows = []; + $align_right = new TableCellStyle(['align' => 'right']); + $styled = fn (int|string $content, TableCellStyle $style): TableCell => + new TableCell( + (string)$content, + ['style' => $style] + ) + ; + foreach ($stat->function_entries as $function_entry) { + $name = $function_entry->name; + $percent = number_format($function_entry->percent_exclusive, 2); + $rows[] = [ + $styled($function_entry->total_count_inclusive, $align_right), + $styled($function_entry->total_count_exclusive, $align_right), + $styled($function_entry->count_inclusive, $align_right), + $styled($function_entry->count_exclusive, $align_right), + $styled($percent, $align_right), + $name, + ]; + if (++$count > $height) { + break; + } + } + + $output = $this->output->section(); + $table = new Table($output); + $table->setColumnMaxWidth(5, max(4, $width - 41)); + $table->setHeaders([ + $styled('total_incl', $align_right), + $styled('total_excl', $align_right), + $styled('incl', $align_right), + $styled('excl', $align_right), + $styled('%', $align_right), + str_pad('name', $padding_name), + ]); + $table->setRows($rows); + $table->setStyle('compact'); + $table->getStyle()->setCellHeaderFormat('%s'); + $table->render(); + $output->overwrite( + preg_replace( + '/( *total_incl.*)/', + '$1', + preg_replace( + '/\e[[][A-Za-z0-9]*;?[0-9]*m?/', + '', + $output->getContent() + ) + ) + ); + } +} diff --git a/src/Lib/DateTime/Clock.php b/src/Lib/DateTime/Clock.php new file mode 100644 index 00000000..884ac7fe --- /dev/null +++ b/src/Lib/DateTime/Clock.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PhpProfiler\Lib\DateTime; + +interface Clock +{ + public function now(): \DateTimeImmutable; +} diff --git a/src/Lib/DateTime/FixedClock.php b/src/Lib/DateTime/FixedClock.php new file mode 100644 index 00000000..994c34c7 --- /dev/null +++ b/src/Lib/DateTime/FixedClock.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PhpProfiler\Lib\DateTime; + +final class FixedClock implements Clock +{ + public function __construct( + private \DateTimeImmutable $now, + ) { + } + + public function now(): \DateTimeImmutable + { + return $this->now; + } + + public function update(\DateTimeImmutable $now): void + { + $this->now = $now; + } +} diff --git a/src/Lib/DateTime/OnDemandClock.php b/src/Lib/DateTime/OnDemandClock.php new file mode 100644 index 00000000..6df5b342 --- /dev/null +++ b/src/Lib/DateTime/OnDemandClock.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PhpProfiler\Lib\DateTime; + +final class OnDemandClock implements Clock +{ + public function now(): \DateTimeImmutable + { + return new \DateTimeImmutable(); + } +} diff --git a/tests/Inspector/Output/TopLike/StatTest.php b/tests/Inspector/Output/TopLike/StatTest.php new file mode 100644 index 00000000..5133a606 --- /dev/null +++ b/tests/Inspector/Output/TopLike/StatTest.php @@ -0,0 +1,263 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PhpProfiler\Inspector\Output\TopLike; + +use PhpProfiler\Lib\PhpProcessReader\CallFrame; +use PhpProfiler\Lib\PhpProcessReader\CallTrace; +use PHPUnit\Framework\TestCase; + +class StatTest extends TestCase +{ + public function testAddTrace() + { + $stat = new Stat(); + $stat->addTrace( + new CallTrace( + new CallFrame( + 'ClassName1', + 'functionName1', + 'file1', + null + ) + ) + ); + $this->assertSame(1, $stat->sample_count); + $this->assertEquals( + new FunctionEntry( + name: 'ClassName1::functionName1', + file: 'file1', + lineno: -1, + count_exclusive: 1, + count_inclusive: 1, + total_count_exclusive: 0, + total_count_inclusive: 0, + percent_exclusive: 0 + ), + $stat->function_entries['ClassName1::functionName1'] + ); + $stat->addTrace( + new CallTrace( + new CallFrame( + 'ClassName2', + 'functionName2', + 'file2', + null + ), + new CallFrame( + 'ClassName1', + 'functionName1', + 'file1', + null + ) + ) + ); + $this->assertSame(2, $stat->sample_count); + $this->assertEquals( + new FunctionEntry( + name: 'ClassName1::functionName1', + file: 'file1', + lineno: -1, + count_exclusive: 1, + count_inclusive: 2, + total_count_exclusive: 0, + total_count_inclusive: 0, + percent_exclusive: 0 + ), + $stat->function_entries['ClassName1::functionName1'] + ); + $this->assertEquals( + new FunctionEntry( + name: 'ClassName2::functionName2', + file: 'file2', + lineno: -1, + count_exclusive: 1, + count_inclusive: 1, + total_count_exclusive: 0, + total_count_inclusive: 0, + percent_exclusive: 0 + ), + $stat->function_entries['ClassName2::functionName2'] + ); + $stat->addTrace( + new CallTrace( + new CallFrame( + 'ClassName1', + 'functionName1', + 'file1', + null + ), + new CallFrame( + 'ClassName2', + 'functionName2', + 'file2', + null + ), + ) + ); + $this->assertSame(3, $stat->sample_count); + $this->assertEquals( + new FunctionEntry( + name: 'ClassName1::functionName1', + file: 'file1', + lineno: -1, + count_exclusive: 2, + count_inclusive: 3, + total_count_exclusive: 0, + total_count_inclusive: 0, + percent_exclusive: 0 + ), + $stat->function_entries['ClassName1::functionName1'] + ); + $this->assertEquals( + new FunctionEntry( + name: 'ClassName2::functionName2', + file: 'file2', + lineno: -1, + count_exclusive: 1, + count_inclusive: 2, + total_count_exclusive: 0, + total_count_inclusive: 0, + percent_exclusive: 0 + ), + $stat->function_entries['ClassName2::functionName2'] + ); + } + + public function testSort() + { + $entry1 = new FunctionEntry( + name: 'ClassName1::functionName1', + file: 'file1', + lineno: -1, + count_exclusive: 1, + count_inclusive: 2, + total_count_exclusive: 0, + total_count_inclusive: 0, + percent_exclusive: 0 + ); + $entry2 = new FunctionEntry( + name: 'ClassName2::functionName2', + file: 'file2', + lineno: -1, + count_exclusive: 1, + count_inclusive: 1, + total_count_exclusive: 1, + total_count_inclusive: 1, + percent_exclusive: 0 + ); + $entry3 = new FunctionEntry( + name: 'ClassName3::functionName3', + file: 'file3', + lineno: -1, + count_exclusive: 1, + count_inclusive: 1, + total_count_exclusive: 1, + total_count_inclusive: 2, + percent_exclusive: 0 + ); + $entry4 = new FunctionEntry( + name: 'ClassName4::functionName4', + file: 'file4', + lineno: -1, + count_exclusive: 1, + count_inclusive: 1, + total_count_exclusive: 2, + total_count_inclusive: 1, + percent_exclusive: 0 + ); + $entry5 = new FunctionEntry( + name: 'ClassName5::functionName5', + file: 'file5', + lineno: -1, + count_exclusive: 2, + count_inclusive: 2, + total_count_exclusive: 2, + total_count_inclusive: 2, + percent_exclusive: 0 + ); + $stat = new Stat( + [ + 'ClassName2::functionName2' => $entry2, + 'ClassName1::functionName1' => $entry1, + 'ClassName3::functionName3' => $entry3, + 'ClassName4::functionName4' => $entry4, + 'ClassName5::functionName5' => $entry5, + ] + ); + $entries = $stat->function_entries; + $this->assertEquals($entry2, current($entries)); + $this->assertEquals($entry1, next($entries)); + $this->assertEquals($entry3, next($entries)); + $this->assertEquals($entry4, next($entries)); + $this->assertEquals($entry5, next($entries)); + $stat->sort(); + $entries = $stat->function_entries; + $this->assertEquals($entry5, current($entries)); + $this->assertEquals($entry1, next($entries)); + $this->assertEquals($entry4, next($entries)); + $this->assertEquals($entry3, next($entries)); + $this->assertEquals($entry2, next($entries)); + } + + public function testCalculateEntryTotals() + { + $stat = new Stat([ + 'ClassName1::functionName1' => new FunctionEntry( + name: 'ClassName1::functionName1', + file: 'file1', + lineno: -1, + count_exclusive: 1, + count_inclusive: 1, + total_count_exclusive: 0, + total_count_inclusive: 0, + percent_exclusive: 0 + ), + 'ClassName2::functionName2' => new FunctionEntry( + name: 'ClassName2::functionName2', + file: 'file2', + lineno: -1, + count_exclusive: 2, + count_inclusive: 3, + total_count_exclusive: 0, + total_count_inclusive: 0, + percent_exclusive: 0 + ), + ]); + $stat->sample_count = 3; + $stat->calculateEntryTotals(); + $entry1 = $stat->function_entries['ClassName1::functionName1']; + $entry2 = $stat->function_entries['ClassName2::functionName2']; + $this->assertSame(1, $entry1->total_count_exclusive); + $this->assertSame(1, $entry1->total_count_inclusive); + $this->assertSame(100 * 1 / 3, $entry1->percent_exclusive); + $this->assertSame(2, $entry2->total_count_exclusive); + $this->assertSame(3, $entry2->total_count_inclusive); + $this->assertSame(100 * 2 / 3, $entry2->percent_exclusive); + } + + public function testUpdateTotalSampleCount() + { + $stat = new Stat(); + $this->assertSame(0, $stat->total_count); + $stat->sample_count = 3; + $stat->updateTotalSampleCount(); + $this->assertSame(3, $stat->total_count); + $stat->clearCurrentSamples(); + $stat->updateTotalSampleCount(); + $this->assertSame(3, $stat->total_count); + $stat->sample_count = 3; + $stat->updateTotalSampleCount(); + $this->assertSame(6, $stat->total_count); + } +} diff --git a/tests/Inspector/Output/TopLike/TopLikeFormatterTest.php b/tests/Inspector/Output/TopLike/TopLikeFormatterTest.php new file mode 100644 index 00000000..fe335c7e --- /dev/null +++ b/tests/Inspector/Output/TopLike/TopLikeFormatterTest.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PhpProfiler\Inspector\Output\TopLike; + +use PhpProfiler\Lib\DateTime\FixedClock; +use PhpProfiler\Lib\PhpProcessReader\CallTrace; +use PHPUnit\Framework\TestCase; + +use function PHPUnit\Framework\assertSame; + +class TopLikeFormatterTest extends TestCase +{ + public function testFormat() + { + $now = new \DateTimeImmutable(); + $formatter = new TopLikeFormatter( + 'regex', + $outputter = new class () implements Outputter { + public int $call_count = 0; + public function display(string $trace_target, Stat $stat): void + { + assertSame('regex', $trace_target); + $this->call_count++; + } + }, + $clock = new FixedClock($now) + ); + $this->assertSame(0, $outputter->call_count); + $formatter->format(new CallTrace()); + + $clock->update($now->modify('+1 second')); + $formatter->format(new CallTrace()); + $this->assertSame(1, $outputter->call_count); + } +} diff --git a/tests/Inspector/Output/TopLike/TopLikeOutputterTest.php b/tests/Inspector/Output/TopLike/TopLikeOutputterTest.php new file mode 100644 index 00000000..39c3cb49 --- /dev/null +++ b/tests/Inspector/Output/TopLike/TopLikeOutputterTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PhpProfiler\Inspector\Output\TopLike; + +use Mockery; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Formatter\OutputFormatter; +use Symfony\Component\Console\Formatter\OutputFormatterInterface; +use Symfony\Component\Console\Formatter\WrappableOutputFormatterInterface; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\ConsoleSectionOutput; +use Symfony\Component\Console\Terminal; + +class TopLikeOutputterTest extends TestCase +{ + public function testDisplay() + { + $output = Mockery::mock(ConsoleOutputInterface::class); + $terminal = Mockery::mock(Terminal::class); + $outputter = new TopLikeOutputter( + $output, + $terminal + ); + + $output->expects()->write("\e[H\e[2J"); + $output->expects()->writeln('target_regex'); + $output->expects()->writeln('samp_count=0 func_count=0 total_count=0'); + $output->expects()->writeln(''); + $terminal->expects()->getWidth()->andReturns(80); + $terminal->expects()->getHeight()->andReturns(20); + $output->expects() + ->section() + ->andReturns( + $section = Mockery::mock(ConsoleSectionOutput::class) + ) + ; + $section->expects() + ->getFormatter() + ->andReturns( + $formatter = new OutputFormatter() + ) + ; + $section + ->expects() + ->writeln( + \Mockery::on(function ($argument) { + $this->assertStringContainsString('total_incl', $argument); + return true; + }) + ) + ; + $section + ->expects() + ->getContent() + ->andReturns('content_rendered') + ; + $section + ->expects() + ->overwrite('content_rendered') + ; + + $outputter->display('target_regex', new Stat()); + } +} diff --git a/tests/Lib/DateTime/FixedClockTest.php b/tests/Lib/DateTime/FixedClockTest.php new file mode 100644 index 00000000..e95c13b0 --- /dev/null +++ b/tests/Lib/DateTime/FixedClockTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PhpProfiler\Lib\DateTime; + +use PHPUnit\Framework\TestCase; + +class FixedClockTest extends TestCase +{ + public function testNow() + { + $clock = new FixedClock(new \DateTimeImmutable()); + $start = $clock->now(); + $diff = $clock->now()->diff($start); + $this->assertSame(0, (int)$diff->format('%f')); + $clock->update(new \DateTimeImmutable()); + $diff = $clock->now()->diff($start); + $this->assertgreaterThan(0, (int)$diff->format('%f')); + } +} diff --git a/tests/Lib/DateTime/OnDemandClockTest.php b/tests/Lib/DateTime/OnDemandClockTest.php new file mode 100644 index 00000000..fb27bd59 --- /dev/null +++ b/tests/Lib/DateTime/OnDemandClockTest.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace PhpProfiler\Lib\DateTime; + +use PHPUnit\Framework\TestCase; + +class OnDemandClockTest extends TestCase +{ + public function testNow() + { + $clock = new OnDemandClock(); + $now = $clock->now(); + $diff = $clock->now()->diff($now); + $this->assertGreaterThan(0, (int)$diff->format('%f')); + } +}