From de201c0a8a6e4d6c1e70ed7a846ae1f0d8f0af6b Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Wed, 19 Aug 2026 08:32:07 -0400 Subject: [PATCH 1/2] feat(ui): add shared profiler normalization and Timeline rendering contracts. --- CHANGELOG.md | 1 + src/Panel/PanelRenderContext.php | 12 + src/Panel/Profile/ProfileCellRenderer.php | 18 +- src/Panel/Profile/ProfilingSnapshot.php | 66 ++++ src/Panel/Timeline/TimelineGeometry.php | 81 +++++ src/Panel/Timeline/TimelineMemoryRenderer.php | 139 ++++++++ src/Panel/Timeline/TimelineRenderer.php | 329 ++++++++++++++++++ src/Panel/Timeline/TimelineSpanRow.php | 6 +- tests/Panel/PanelRenderContextTest.php | 24 ++ .../Panel/Profile/ProfileCellRendererTest.php | 15 + tests/Panel/Profile/ProfilingSnapshotTest.php | 110 ++++++ tests/Panel/Timeline/TimelineGeometryTest.php | 77 ++++ .../Timeline/TimelineMemoryRendererTest.php | 58 +++ tests/Panel/Timeline/TimelineRendererTest.php | 125 +++++++ tests/Panel/Timeline/TimelineSpanRowTest.php | 5 + 15 files changed, 1063 insertions(+), 3 deletions(-) create mode 100644 src/Panel/Timeline/TimelineGeometry.php create mode 100644 src/Panel/Timeline/TimelineMemoryRenderer.php create mode 100644 src/Panel/Timeline/TimelineRenderer.php create mode 100644 tests/Panel/Profile/ProfilingSnapshotTest.php create mode 100644 tests/Panel/Timeline/TimelineGeometryTest.php create mode 100644 tests/Panel/Timeline/TimelineMemoryRendererTest.php create mode 100644 tests/Panel/Timeline/TimelineRendererTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2cd86..682e1d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,3 +18,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat(panel): add `UserRbacRow` typed view-model so adapters render RBAC role and permission rows from a single normalized shape. - feat: add shared UI parity contracts, Asset composition, and cross-adapter acceptance documentation. - feat(ui): share database EXPLAIN markup across debugger adapters. +- feat(ui): add shared profiler normalization and Timeline rendering contracts. diff --git a/src/Panel/PanelRenderContext.php b/src/Panel/PanelRenderContext.php index db73a82..37168ab 100644 --- a/src/Panel/PanelRenderContext.php +++ b/src/Panel/PanelRenderContext.php @@ -17,6 +17,7 @@ * @param array $queryParams Parsed query parameters of the current debugger request. * @param string $theme Resolved debugger theme. * @param DebugUrlGeneratorInterface $urls Adapter-owned URL generator. + * @param array> $panels Serialized panel payloads from the current snapshot. */ public function __construct( public string $tag, @@ -24,6 +25,7 @@ public function __construct( public array $queryParams, public string $theme, private DebugUrlGeneratorInterface $urls, + private array $panels = [], ) {} /** @@ -47,6 +49,16 @@ public function historyUrl(array|null $queryParams = null): string return $this->urls->history($queryParams ?? $this->queryParams); } + /** + * Returns another panel payload from the current snapshot when cross-panel composition is required. + * + * @return array|null Serialized panel payload, or `null` when it was not captured. + */ + public function panelPayload(string $panel): array|null + { + return $this->panels[$panel] ?? null; + } + /** * Builds a panel URL for this captured request. * diff --git a/src/Panel/Profile/ProfileCellRenderer.php b/src/Panel/Profile/ProfileCellRenderer.php index b046bd3..25c9210 100644 --- a/src/Panel/Profile/ProfileCellRenderer.php +++ b/src/Panel/Profile/ProfileCellRenderer.php @@ -23,7 +23,10 @@ final class ProfileCellRenderer /** * Category prefix of the profiled blocks whose info is a raw SQL statement. */ - private const string SQL_CATEGORY_PREFIX = 'yii\db\Command::'; + private const array SQL_CATEGORY_PREFIXES = [ + 'yii\db\Command::', + 'Yiisoft\Db\Command::', + ]; /** * Renders the category as the shared {@see Fqcn::renderLabel()} two-tone label: muted namespace prefix plus a @@ -66,7 +69,7 @@ public static function renderInfoCell(ProfileRow $row): string ->content('→') ->render(); - $body = str_starts_with($row->category, self::SQL_CATEGORY_PREFIX) + $body = self::isSqlCategory($row->category) ? Div::tag()->class('yii-debug-db-sql') ->html(SqlHighlighter::highlight($row->info)) ->render() @@ -92,4 +95,15 @@ public static function renderTimeCell(ProfileRow $row): string ->content(date('H:i:s.', (int) $seconds) . $suffix) ->render(); } + + private static function isSqlCategory(string $category): bool + { + foreach (self::SQL_CATEGORY_PREFIXES as $prefix) { + if (str_starts_with($category, $prefix)) { + return true; + } + } + + return false; + } } diff --git a/src/Panel/Profile/ProfilingSnapshot.php b/src/Panel/Profile/ProfilingSnapshot.php index 39e1f99..a8e99ac 100644 --- a/src/Panel/Profile/ProfilingSnapshot.php +++ b/src/Panel/Profile/ProfilingSnapshot.php @@ -11,6 +11,7 @@ use function array_map; use function count; use function is_array; +use function usort; /** * Canonical profiling snapshot holding the request metrics, the resolved profile blocks, and the memory samples that @@ -67,6 +68,71 @@ public static function capture(int $memory, float $time, array $messages): self return new self($memory, $time, $entries, $samples); } + /** + * Normalizes completed profiler messages carrying token, category, timing, nesting, memory, and trace context. + * + * @param array $messages Completed profiler messages. + */ + public static function captureCompleted(int $memory, float $time, array $messages): self + { + $entries = []; + $samples = []; + + foreach ($messages as $message) { + if (!is_array($message)) { + continue; + } + + $context = $message['context'] ?? null; + + if (!is_array($context)) { + continue; + } + + $beginTime = Coerce::floatOrNull($context['beginTime'] ?? $context['time'] ?? null); + $endTime = Coerce::floatOrNull($context['endTime'] ?? null); + $duration = Coerce::floatOrNull($context['duration'] ?? null); + + if ($beginTime === null || $duration === null) { + continue; + } + + $beginMemory = Coerce::intOrNull($context['beginMemory'] ?? null); + $endMemory = Coerce::intOrNull($context['endMemory'] ?? $context['memory'] ?? null); + $memoryDiff = Coerce::intOrNull($context['memoryDiff'] ?? null) + ?? (($beginMemory !== null && $endMemory !== null) ? $endMemory - $beginMemory : 0); + $row = ProfileRow::fromTiming( + [ + 'timestamp' => $beginTime, + 'duration' => $duration, + 'category' => $context['category'] ?? $message['category'] ?? '', + 'info' => $message['token'] ?? '', + 'level' => $context['nestedLevel'] ?? 0, + 'memory' => $endMemory ?? 0, + 'memoryDiff' => $memoryDiff, + 'trace' => $context['trace'] ?? [], + ], + count($entries), + ); + + if ($row !== null) { + $entries[] = $row; + } + + if ($beginMemory !== null) { + $samples[] = new MemorySample($beginTime * 1000, $beginMemory); + } + + if ($endTime !== null && $endMemory !== null) { + $samples[] = new MemorySample($endTime * 1000, $endMemory); + } + } + + usort($samples, static fn(MemorySample $a, MemorySample $b): int => $a->time <=> $b->time); + + return new self($memory, $time, $entries, $samples); + } + /** * @return list Resolved profile blocks in capture order. */ diff --git a/src/Panel/Timeline/TimelineGeometry.php b/src/Panel/Timeline/TimelineGeometry.php new file mode 100644 index 0000000..467eaa2 --- /dev/null +++ b/src/Panel/Timeline/TimelineGeometry.php @@ -0,0 +1,81 @@ + Ruler offsets. + */ + public static function rulers(float $duration, int $line = 6): array + { + if ($line < 1 || $duration <= 0.0) { + return []; + } + + $rough = $duration / $line; + + $magnitude = 10 ** max(0, (int) floor(log10($rough))); + + $normalized = $rough / $magnitude; + + $step = match (true) { + $normalized <= 1.0 => $magnitude, + $normalized <= 2.0 => 2 * $magnitude, + $normalized <= 5.0 => 5 * $magnitude, + default => 10 * $magnitude, + }; + + $ticks = [0 => 0.0]; + $limit = $duration - $step / 4; + + if ($step > $limit) { + return $ticks; + } + + foreach (range($step, $limit, $step) as $milliseconds) { + $ticks[(int) $milliseconds] = $milliseconds / $duration * 100; + } + + return $ticks; + } + /** + * Converts captured profile rows into positioned timeline spans. + * + * @param list $rows Profile rows in capture order. + * + * @return list Positioned spans in capture order. + */ + public static function spans(array $rows, float $start, float $duration): array + { + if ($duration <= 0.0) { + return []; + } + + $spans = []; + + foreach ($rows as $row) { + $spans[] = TimelineSpanRow::from( + $row, + ($row->timestamp - $start) / $duration * 100, + $row->duration / $duration * 100, + ); + } + + return $spans; + } +} diff --git a/src/Panel/Timeline/TimelineMemoryRenderer.php b/src/Panel/Timeline/TimelineMemoryRenderer.php new file mode 100644 index 0000000..ab86cfa --- /dev/null +++ b/src/Panel/Timeline/TimelineMemoryRenderer.php @@ -0,0 +1,139 @@ + 0.18, + 60 => 0.45, + 90 => 0.65, + 100 => 0.85, + ]; + + /** + * Renders an SVG memory graph, or `''` when its geometry cannot be resolved. + * + * @param list $samples Memory samples in any order. + */ + public static function render( + array $samples, + float $start, + float $duration, + int $memory, + int $width = 1920, + int $height = 40, + ): string { + if ($samples === [] || $duration <= 0.0 || $memory <= 0 || $width <= 0 || $height <= 0) { + return ''; + } + + $points = []; + + foreach ($samples as $sample) { + $points[] = [ + ($sample->time - $start) / $duration * $width, + $height - ($sample->memory / $memory * $height), + ]; + } + + usort($points, static fn(array $a, array $b): int => $a[0] <=> $b[0]); + + return Svg::tag() + ->height($height) + ->html( + Defs::tag()->html(self::gradient()), + G::tag()->html( + Polygon::tag() + ->points(self::polygonPoints($points, $width, $height)) + ->fill('url(#yii-debug-tl-memory-gradient)'), + Polyline::tag() + ->points(self::polylinePoints($points, $width, $height)) + ->fill('none') + ->stroke('currentColor') + ->strokeWidth('1.5'), + ), + ) + ->preserveAspectRatio('none') + ->viewBox("0 0 {$width} {$height}") + ->width($width) + ->xmlns('http://www.w3.org/2000/svg') + ->render(); + } + + private static function gradient(): LinearGradient + { + $stops = []; + + foreach (self::GRADIENT as $percent => $opacity) { + $stops[] = Stop::tag() + ->offset("{$percent}%") + ->stopColor('currentColor') + ->stopOpacity(self::number($opacity)); + } + + return LinearGradient::tag() + ->id('yii-debug-tl-memory-gradient') + ->x1(0) + ->x2(0) + ->y1(1) + ->y2(0) + ->html(...$stops); + } + + private static function number(float|int $value): string + { + $rendered = rtrim(sprintf('%.6F', $value), '0'); + + return rtrim($rendered, '.'); + } + + /** + * @param list $points + */ + private static function polygonPoints(array $points, int $width, int $height): string + { + $rendered = "0 {$height}"; + + $lastY = (float) $height; + + foreach ($points as [$x, $y]) { + $rendered .= ' ' . self::number($x) . ' ' . self::number($y); + $lastY = $y; + } + + return $rendered + . ' ' . self::number($width - 0.001) . ' ' . self::number($lastY) + . " {$width} {$height}"; + } + + /** + * @param list $points + */ + private static function polylinePoints(array $points, int $width, int $height): string + { + $rendered = "0 {$height}"; + + $lastY = (float) $height; + + foreach ($points as [$x, $y]) { + $rendered .= ' ' . self::number($x) . ' ' . self::number($y); + + $lastY = $y; + } + + return $rendered . " {$width} " . self::number($lastY); + } +} diff --git a/src/Panel/Timeline/TimelineRenderer.php b/src/Panel/Timeline/TimelineRenderer.php new file mode 100644 index 0000000..ef64b64 --- /dev/null +++ b/src/Panel/Timeline/TimelineRenderer.php @@ -0,0 +1,329 @@ + 'Application', + 'db' => 'Database', + 'view' => 'View', + 'cache' => 'Cache', + 'mail' => 'Mail', + 'queue' => 'Queue', + 'other' => 'Other', + ]; + + /** + * Renders the complete timeline chart from prepared spans and ruler offsets. + * + * @param list $rows Positioned timeline spans. + * @param array $rulers Ruler offsets keyed by milliseconds. + */ + public static function renderChart( + array $rows, + array $rulers, + string $memorySvg = '', + int $memory = 0, + int $memoryHeight = 40, + ): string { + if ($rows === []) { + return ''; + } + + $children = [ + self::renderAxis($rulers), + ...self::renderLegend($rows), + self::renderRows($rows), + ]; + + if ($memorySvg !== '') { + $children[] = self::renderMemoryFooter($memorySvg, $memory, $memoryHeight); + } + + return Section::tag() + ->class('yii-debug-tl') + ->html(...$children) + ->render(); + } + + /** + * Renders the empty-state hint linking to the sortable Profiling panel. + */ + public static function renderEmptyHint(bool $hasRows, string $profilingUrl): string + { + if ($hasRows) { + return ''; + } + + return Div::tag() + ->class('yii-debug-tl-hint') + ->html( + P::tag() + ->class('yii-debug-tl-hint-title') + ->content('No spans matched your filter.'), + P::tag() + ->class('yii-debug-tl-hint-body') + ->html( + 'The timeline is most useful for requests that take hundreds of milliseconds, where you can ', + Em::tag() + ->content('see'), + ' which operations dominate. For quick requests the ', + A::tag() + ->href($profilingUrl) + ->content('Profiling panel'), + ' presents the same data as a sortable list easier to scan.', + ), + ) + ->render(); + } + + /** + * Renders the filter form while preserving adapter-owned route parameters. + * + * @param array $hiddenParams Hidden route and theme parameters. + */ + public static function renderFilterForm( + string $action, + array $hiddenParams, + string $duration, + string $category, + ): string { + $children = []; + + foreach ($hiddenParams as $name => $value) { + $children[] = InputHidden::tag()->name($name)->value($value); + } + + $children[] = Div::tag() + ->class('yii-debug-tl-field') + ->html( + Label::tag() + ->content('Min duration (ms)') + ->for('tl-duration'), + InputNumber::tag() + ->id('tl-duration') + ->min(0) + ->name('Timeline[duration]') + ->placeholder('0') + ->step(0.1) + ->value($duration), + ); + $children[] = Div::tag() + ->class('yii-debug-tl-field yii-debug-tl-field-grow') + ->html( + Label::tag() + ->content('Category') + ->for('tl-category'), + InputText::tag() + ->id('tl-category') + ->name('Timeline[category]') + ->placeholder('yii\\db\\Command::query') + ->value($category), + ); + $children[] = Button::tag() + ->class('yii-debug-btn yii-debug-btn-primary yii-debug-btn-sm') + ->content('Apply') + ->type('submit'); + + return Form::tag() + ->action($action) + ->class('yii-debug-tl-filter') + ->html(...$children) + ->method('get') + ->render(); + } + + /** + * Renders total duration, peak memory, and visible span count. + */ + public static function renderSummary(float $duration, int $memory, int $spanCount): string + { + return Header::tag() + ->class('yii-debug-grid-summary') + ->html( + Span::tag() + ->html( + Strong::tag() + ->content(number_format($duration)), + ' ms total', + ), + Span::tag() + ->class('yii-debug-grid-summary-sep') + ->content('·'), + Span::tag() + ->html( + Strong::tag() + ->content( + Format::bytesToMb($memory)), + ' peak memory', + ), + Span::tag() + ->class('yii-debug-grid-summary-sep') + ->content('·'), + Span::tag() + ->html(Strong::tag() + ->content( + (string) $spanCount), + ' spans', + ), + ) + ->render(); + } + + private static function formatTickLabel(int $milliseconds): string + { + if ($milliseconds < 1000) { + return "{$milliseconds} ms"; + } + + $seconds = rtrim(rtrim(sprintf('%.1f', $milliseconds / 1000), '0'), '.'); + + return "{$seconds} s"; + } + + /** + * @param array $rulers Ruler offsets keyed by milliseconds. + */ + private static function renderAxis(array $rulers): Header + { + $ticks = []; + + foreach ($rulers as $milliseconds => $left) { + $ticks[] = Span::tag() + ->class('yii-debug-tl-tick') + ->content(self::formatTickLabel($milliseconds)) + ->style(['left' => Format::cssPercent($left)]); + } + + return Header::tag()->class('yii-debug-tl-axis')->html(...$ticks); + } + + /** + * @param list $rows Positioned timeline spans. + * + * @return list
Legend container, or an empty list for a single category. + */ + private static function renderLegend(array $rows): array + { + $present = []; + + foreach ($rows as $row) { + $present[$row->variant] = true; + } + + if (count($present) < 2) { + return []; + } + + $items = []; + + foreach (self::LEGEND_LABELS as $variant => $label) { + if (!isset($present[$variant])) { + continue; + } + + $items[] = Span::tag() + ->class("yii-debug-tl-legend-item yii-debug-tl-row-{$variant}") + ->html( + Span::tag() + ->class('yii-debug-tl-dot') + ->addAttribute('aria-hidden', 'true'), + Span::tag() + ->class('yii-debug-tl-legend-label') + ->content($label), + ); + } + + return [Div::tag()->class('yii-debug-tl-legend')->html(...$items)]; + } + + private static function renderMemoryFooter(string $svg, int $memory, int $height): Footer + { + return Footer::tag() + ->class('yii-debug-tl-memory') + ->html( + Span::tag() + ->class('yii-debug-tl-memory-label') + ->content('Memory'), + Div::tag() + ->class('yii-debug-tl-memory-track') + ->html($svg) + ->style(['height' => "{$height}px"]), + Span::tag() + ->class('yii-debug-tl-memory-peak') + ->content(Format::bytesToMb($memory)), + ); + } + + private static function renderRow(TimelineSpanRow $row): Div + { + return Div::tag() + ->addAttribute('role', 'listitem') + ->class("yii-debug-tl-row yii-debug-tl-row-{$row->variant}") + ->html( + Div::tag() + ->class('yii-debug-tl-label') + ->style(['--depth' => $row->depth]) + ->html( + Span::tag() + ->class('yii-debug-tl-dot') + ->addAttribute('aria-hidden', 'true'), + Span::tag() + ->class('yii-debug-tl-name') + ->html(Fqcn::renderLabel($row->category)), + ), + Div::tag() + ->class('yii-debug-tl-track') + ->html( + Div::tag() + ->class('yii-debug-tl-bar') + ->style([ + 'left' => $row->cssLeft . '%', + 'width' => $row->cssWidth . '%', + ]) + ->html( + Span::tag() + ->class('yii-debug-tl-bar-duration') + ->content(sprintf('%.1f ms', $row->duration)), + ), + ), + ) + ->title($row->tooltip); + } + + /** + * @param list $rows Positioned timeline spans. + */ + private static function renderRows(array $rows): Div + { + $rendered = []; + + foreach ($rows as $row) { + $rendered[] = self::renderRow($row); + } + + return Div::tag() + ->class('yii-debug-tl-rows') + ->addAttribute('role', 'list') + ->html(...$rendered); + } +} diff --git a/src/Panel/Timeline/TimelineSpanRow.php b/src/Panel/Timeline/TimelineSpanRow.php index 28138ea..294a159 100644 --- a/src/Panel/Timeline/TimelineSpanRow.php +++ b/src/Panel/Timeline/TimelineSpanRow.php @@ -128,7 +128,11 @@ private static function numberToString(float $value): string */ private static function variantOf(string $category): string { - if (str_contains($category, 'db\\') || str_contains($category, 'Command')) { + if ( + str_contains($category, 'db\\') + || str_contains($category, 'Db\\') + || str_contains($category, 'Command') + ) { return 'db'; } diff --git a/tests/Panel/PanelRenderContextTest.php b/tests/Panel/PanelRenderContextTest.php index f1ec69b..6d8f97b 100644 --- a/tests/Panel/PanelRenderContextTest.php +++ b/tests/Panel/PanelRenderContextTest.php @@ -72,6 +72,30 @@ public function testBuildsUrlsWithExplicitTargetsAndParameters(): void ); } + public function testReturnsCrossPanelPayloadWhenCaptured(): void + { + $context = new PanelRenderContext( + 'request-1', + 'timeline', + [], + 'light', + self::urlGenerator(), + [ + 'profiling' => ['time' => 0.125, 'entries' => []], + ], + ); + + self::assertSame( + ['time' => 0.125, 'entries' => []], + $context->panelPayload('profiling'), + 'Captured sibling payloads must remain available to context-aware panels.', + ); + self::assertNull( + $context->panelPayload('missing'), + 'Missing sibling payloads must resolve to null.', + ); + } + private static function urlGenerator(): DebugUrlGeneratorInterface { return new class implements DebugUrlGeneratorInterface { diff --git a/tests/Panel/Profile/ProfileCellRendererTest.php b/tests/Panel/Profile/ProfileCellRendererTest.php index 8dd384c..26dcddd 100644 --- a/tests/Panel/Profile/ProfileCellRendererTest.php +++ b/tests/Panel/Profile/ProfileCellRendererTest.php @@ -161,6 +161,21 @@ public function testRenderInfoCellHighlightsSqlForDbCommandBlocks(): void ); } + public function testRenderInfoCellHighlightsSqlForYii3DbCommandBlocks(): void + { + self::assertSame( + <<<'HTML' +
+ SELECT 1 +
+ HTML, + ProfileCellRenderer::renderInfoCell( + self::makeRow(category: 'Yiisoft\\Db\\Command::query', info: 'SELECT 1'), + ), + 'Yii3 DB command categories must use the exact shared SQL presentation.', + ); + } + public function testRenderInfoCellKeepsPlainInfoUnhighlighted(): void { $html = ProfileCellRenderer::renderInfoCell(self::makeRow(category: 'application', info: 'SELECT me')); diff --git a/tests/Panel/Profile/ProfilingSnapshotTest.php b/tests/Panel/Profile/ProfilingSnapshotTest.php new file mode 100644 index 0000000..ce0ab25 --- /dev/null +++ b/tests/Panel/Profile/ProfilingSnapshotTest.php @@ -0,0 +1,110 @@ + 'SELECT 1', + 'context' => [ + 'category' => 'Yiisoft\\Db\\Command::query', + 'beginTime' => 100.05, + 'endTime' => 100.06, + 'duration' => 0.01, + 'beginMemory' => 2_048, + 'endMemory' => 3_072, + 'nestedLevel' => 1, + 'trace' => [['file' => '/app/index.php', 'line' => 12]], + ], + ], + 'malformed', + ['token' => 'missing context'], + [ + 'token' => 'GET /', + 'context' => [ + 'category' => 'Yii3\\Application::handle', + 'beginTime' => 100.0, + 'endTime' => 100.2, + 'duration' => 0.2, + 'beginMemory' => 1_024, + 'endMemory' => 4_096, + 'memoryDiff' => 3_072, + 'nestedLevel' => 0, + ], + ], + ], + ); + + self::assertSame( + [ + 'memory' => 4_096, + 'time' => 0.2, + 'entries' => [ + [ + 'timestamp' => 100_050.0, + 'duration' => 10.0, + 'category' => 'Yiisoft\\Db\\Command::query', + 'info' => 'SELECT 1', + 'level' => 1, + 'seq' => 0, + 'memory' => 3_072, + 'memoryDiff' => 1_024, + 'trace' => [['file' => '/app/index.php', 'line' => 12]], + ], + [ + 'timestamp' => 100_000.0, + 'duration' => 200.0, + 'category' => 'Yii3\\Application::handle', + 'info' => 'GET /', + 'level' => 0, + 'seq' => 1, + 'memory' => 4_096, + 'memoryDiff' => 3_072, + 'trace' => [], + ], + ], + 'samples' => [ + ['time' => 100_000.0, 'memory' => 1_024], + ['time' => 100_050.0, 'memory' => 2_048], + ['time' => 100_060.0, 'memory' => 3_072], + ['time' => 100_200.0, 'memory' => 4_096], + ], + ], + $snapshot->jsonSerialize(), + 'Completed profiler messages must preserve timing, nesting, memory, trace, and capture order exactly.', + ); + } + + public function testCaptureCompletedSkipsMessagesWithoutUsableTiming(): void + { + $snapshot = ProfilingSnapshot::captureCompleted( + 0, + 0.0, + [ + ['context' => ['beginTime' => 1.0]], + ['context' => ['duration' => 0.1]], + ['context' => 'invalid'], + ], + ); + + self::assertSame([], $snapshot->entries(), 'Incomplete messages must not produce profile rows.'); + self::assertSame([], $snapshot->samples(), 'Incomplete messages without memory must not produce samples.'); + } +} diff --git a/tests/Panel/Timeline/TimelineGeometryTest.php b/tests/Panel/Timeline/TimelineGeometryTest.php new file mode 100644 index 0000000..28b006b --- /dev/null +++ b/tests/Panel/Timeline/TimelineGeometryTest.php @@ -0,0 +1,77 @@ + 0.0, 20 => 20.0, 40 => 40.0, 60 => 60.0, 80 => 80.0], + TimelineGeometry::rulers(100.0), + 'A 100 ms request must use uncluttered 20 ms ticks.', + ); + self::assertSame( + [], + TimelineGeometry::rulers(0.0), + 'A zero duration must not emit rulers.', + ); + self::assertSame( + [], + TimelineGeometry::rulers(100.0, 0), + 'A disabled ruler must not emit ticks.', + ); + } + + public function testSpansUseTheSharedRequestGeometry(): void + { + $spans = TimelineGeometry::spans( + [ + new ProfileRow(1_025.0, 10.0, 'Yiisoft\\Db\\Command::query', 'SELECT 1', 1, 0, 0, 0, []), + ], + 1_000.0, + 100.0, + ); + + self::assertCount( + 1, + $spans, + 'Every profile row must produce one positioned span.', + ); + + $span = $spans[0] ?? self::fail('Expected one positioned span.'); + + self::assertSame( + '25', + $span->cssLeft, + 'Timestamp offset must become a percentage.', + ); + self::assertSame( + '10', + $span->cssWidth, + 'Duration must become a percentage.', + ); + self::assertSame( + 1, + $span->depth, + 'Profiler nesting must reach the span row.', + ); + self::assertSame( + [], + TimelineGeometry::spans([], 0.0, 0.0), + 'A zero duration must not produce spans.', + ); + } +} diff --git a/tests/Panel/Timeline/TimelineMemoryRendererTest.php b/tests/Panel/Timeline/TimelineMemoryRendererTest.php new file mode 100644 index 0000000..b6ddbe0 --- /dev/null +++ b/tests/Panel/Timeline/TimelineMemoryRendererTest.php @@ -0,0 +1,58 @@ + + + + + + + + + + HTML, + TimelineMemoryRenderer::render( + [new MemorySample(1_000.0, 50), new MemorySample(1_050.0, 75)], + 1_000.0, + 100.0, + 100, + 100, + 20, + ), + 'Memory samples must render the exact shared gradient, polygon, and polyline contract.', + ); + } + + public function testRenderReturnsEmptyStringForInvalidGeometry(): void + { + self::assertSame( + '', + TimelineMemoryRenderer::render([], 0.0, 1.0, 1), + 'No samples must omit the SVG.', + ); + self::assertSame( + '', + TimelineMemoryRenderer::render([new MemorySample(0.0, 1)], 0.0, 0.0, 1), + 'A zero duration must omit the SVG.', + ); + } +} diff --git a/tests/Panel/Timeline/TimelineRendererTest.php b/tests/Panel/Timeline/TimelineRendererTest.php new file mode 100644 index 0000000..a077a21 --- /dev/null +++ b/tests/Panel/Timeline/TimelineRendererTest.php @@ -0,0 +1,125 @@ + +
+ 0 ms50 ms +
+ ApplicationDatabase +
+
+
+ Yii3\Application::handle +
+
+ 50.0 ms +
+
+
+
+ Yiisoft\Db\Command::query +
+
+ 10.0 ms +
+
+
+
+ Memory
+ +
2.00 MB +
+ + HTML, + TimelineRenderer::renderChart($rows, [0 => 0.0, 50 => 50.0], '', 2_097_152, 20), + 'Axis, legend, nested rows, bars, and memory footer must render exactly.', + ); + self::assertSame( + '', + TimelineRenderer::renderChart([], []), + 'A chart without spans must stay empty.', + ); + } + public function testRenderFilterFormProducesExactSharedMarkup(): void + { + self::assertSame( + <<<'HTML' +
+
+ +
+ +
+
+ HTML, + TimelineRenderer::renderFilterForm( + '/debug/view', + ['tag' => 'request-1', 'panel' => 'timeline'], + '5', + 'Yiisoft\\Db', + ), + 'The shared Timeline form must preserve hidden routing parameters and filter values exactly.', + ); + } + + public function testRenderHintAndSummaryProduceExactSharedMarkup(): void + { + self::assertSame( + <<<'HTML' +
+ 123 ms total·2.00 MB peak memory·2 spans +
+ HTML, + TimelineRenderer::renderSummary(123.4, 2_097_152, 2), + 'Timeline totals must render exactly.', + ); + self::assertSame( + <<<'HTML' +
+

+ No spans matched your filter. +

+ The timeline is most useful for requests that take hundreds of milliseconds, where you can see which operations dominate. For quick requests the Profiling panel presents the same data as a sortable list easier to scan. +

+
+ HTML, + TimelineRenderer::renderEmptyHint(false, '/debug/view?tag=request-1&panel=profiling'), + 'The empty-state guidance and Profiling link must render exactly.', + ); + self::assertSame( + '', + TimelineRenderer::renderEmptyHint(true, '/profiling'), + 'Visible spans must omit the hint.', + ); + } +} diff --git a/tests/Panel/Timeline/TimelineSpanRowTest.php b/tests/Panel/Timeline/TimelineSpanRowTest.php index 56f5e0c..29bfe6e 100644 --- a/tests/Panel/Timeline/TimelineSpanRowTest.php +++ b/tests/Panel/Timeline/TimelineSpanRowTest.php @@ -187,6 +187,11 @@ public function testFromMapsDbCategoryToDbVariant(): void self::spanFor('SomeCommand::execute')->variant, 'Command spans must map to `db`.', ); + self::assertSame( + 'db', + self::spanFor('Yiisoft\\Db\\Connection::open')->variant, + 'Yii3 database namespaces must map to `db`.', + ); } public function testFromMapsMailAndQueueCategoriesToTheirOwnVariants(): void From a9744c93c83d6ef4a4048978c03fc6f91d35dbd1 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Wed, 19 Aug 2026 08:33:26 -0400 Subject: [PATCH 2/2] Fix ECS ci. --- src/Panel/Timeline/TimelineRenderer.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Panel/Timeline/TimelineRenderer.php b/src/Panel/Timeline/TimelineRenderer.php index ef64b64..cd75eb0 100644 --- a/src/Panel/Timeline/TimelineRenderer.php +++ b/src/Panel/Timeline/TimelineRenderer.php @@ -164,8 +164,8 @@ public static function renderSummary(float $duration, int $memory, int $spanCoun ->html( Strong::tag() ->content(number_format($duration)), - ' ms total', - ), + ' ms total', + ), Span::tag() ->class('yii-debug-grid-summary-sep') ->content('·'), @@ -173,16 +173,19 @@ public static function renderSummary(float $duration, int $memory, int $spanCoun ->html( Strong::tag() ->content( - Format::bytesToMb($memory)), - ' peak memory', + Format::bytesToMb($memory) ), + ' peak memory', + ), Span::tag() ->class('yii-debug-grid-summary-sep') ->content('·'), Span::tag() - ->html(Strong::tag() + ->html( + Strong::tag() ->content( - (string) $spanCount), + (string) $spanCount + ), ' spans', ), )