diff --git a/src/Comparison/SummaryMetricComparison.php b/src/Comparison/SummaryMetricComparison.php index e7080d0..121098a 100644 --- a/src/Comparison/SummaryMetricComparison.php +++ b/src/Comparison/SummaryMetricComparison.php @@ -6,6 +6,7 @@ use PHPForge\Debug\Helper\Format; use PHPForge\Debug\Storage\RequestSummary; +use PHPForge\Debug\View\ViewMessage; use function number_format; @@ -50,17 +51,17 @@ public static function between(RequestSummary $baseline, RequestSummary $target) self::status($target->statusCode), ), self::textMetric( - 'Method', + ViewMessage::METHOD->value, $baseline->method, $target->method, ), self::textMetric( - 'AJAX', + ViewMessage::AJAX->value, self::yesNo($baseline->ajax), self::yesNo($target->ajax), ), self::nullableFloatMetric( - 'Duration', + ViewMessage::DURATION->value, $baseline->processingTime, $target->processingTime, Format::MILLISECONDS_PER_SECOND, @@ -68,7 +69,7 @@ public static function between(RequestSummary $baseline, RequestSummary $target) 'profiling', ), self::nullableFloatMetric( - 'Peak memory', + ViewMessage::PEAK_MEMORY->value, $baseline->peakMemory, $target->peakMemory, 1 / Format::BYTES_PER_MB, @@ -275,7 +276,7 @@ private static function textMetric(string $label, string $baseline, string $targ label: $label, baseline: $baseline, target: $target, - delta: $baseline === $target ? 'No change' : 'Changed', + delta: $baseline === $target ? ViewMessage::NO_CHANGE->value : ViewMessage::CHANGED->value, trend: 'neutral', ); } diff --git a/src/Panel/Asset/AssetMessage.php b/src/Panel/Asset/AssetMessage.php new file mode 100644 index 0000000..a7d92a1 --- /dev/null +++ b/src/Panel/Asset/AssetMessage.php @@ -0,0 +1,267 @@ +value; /** * @var string Stable identifier associating the panel with the captured asset payload. */ - protected const string ID = 'asset'; - + protected const string ID = AssetMessage::ID->value; /** * @var string Panel title used in the debugger navigation. */ - protected const string TITLE = 'Asset Bundles'; - - /** - * @var string Placeholder shown wherever the capture left a field empty. - */ - private const string PLACEHOLDER = '—'; + protected const string TITLE = AssetMessage::TITLE->value; /** * Builds the panel view from the decoded asset capture. @@ -68,11 +61,17 @@ public function present(array $data): PanelView $view = PanelView::create() ->active($count > 0 || $vite !== null) - ->summary($count === 1 ? ' bundle' : ' bundles', $count) - ->summary(' css', $css) - ->summary(' js', $js) - ->summary($depends === 1 ? ' link' : ' links', $depends) - ->toolbar('Bundles', $count); + ->summary( + $count === 1 ? AssetMessage::BUNDLE_SUFFIX->value : AssetMessage::BUNDLES_SUFFIX->value, + $count, + ) + ->summary(AssetMessage::CSS_SUFFIX->value, $css) + ->summary(AssetMessage::JS_SUFFIX->value, $js) + ->summary( + $depends === 1 ? AssetMessage::LINK_SUFFIX->value : AssetMessage::LINKS_SUFFIX->value, + $depends, + ) + ->toolbar(AssetMessage::TOOLBAR->value, $count); if ($vite !== null) { $view = self::vite($view, $vite); @@ -80,29 +79,35 @@ public function present(array $data): PanelView if ($count === 0) { return $view->emptyState( - 'No asset bundles loaded', + AssetMessage::EMPTY_HEADLINE->value, [ - 'This request did not register any ', - PanelView::code('yii\\web\\AssetBundle'), - ' via ', - PanelView::code('register()'), - ', so the inventory is empty.', + AssetMessage::EMPTY_REGISTER->value, + PanelView::code(AssetMessage::BUNDLE_CLASS->value), + AssetMessage::EMPTY_VIA->value, + PanelView::code(AssetMessage::REGISTER_CALL->value), + AssetMessage::EMPTY_INVENTORY->value, ], [ - 'Bundles appear here when something in the request actively pulls them in, typically a layout or ', - 'view that calls a bundle\'s ', - PanelView::code('register()'), - ', or any bundle reached transitively through the ', - PanelView::code('depends'), - ' chain.', + AssetMessage::EMPTY_TRIGGER->value, + AssetMessage::EMPTY_TRIGGER_CALL->value, + PanelView::code(AssetMessage::REGISTER_CALL->value), + AssetMessage::EMPTY_TRANSITIVE_REACH->value, + PanelView::code(AssetMessage::DEPENDS_PROPERTY->value), + AssetMessage::EMPTY_TRANSITIVE->value, ], ); } $view = $view - ->heading('Registered bundles', true) + ->heading(AssetMessage::REGISTERED->value, true) ->table( - ['#', 'Bundle', 'CSS', 'JS', 'Depends'], + [ + AssetMessage::NUMBER->value, + AssetMessage::BUNDLE->value, + AssetMessage::CSS->value, + AssetMessage::JS->value, + AssetMessage::DEPENDS->value, + ], self::inventory($bundles), true, [ @@ -116,7 +121,10 @@ public function present(array $data): PanelView foreach ($bundles as $index => $bundle) { $view = $view - ->heading(sprintf('%d. %s', $index + 1, Fqcn::shortName($bundle->name)), true) + ->heading( + sprintf(AssetMessage::BUNDLE_HEADING->value, $index + 1, Fqcn::shortName($bundle->name)), + true, + ) ->group($bundle->name, self::detail($bundle)); } @@ -136,11 +144,13 @@ private static function detail(AssetBundleRow $bundle): PanelView $view = PanelView::create()->overview( [ - 'Class' => PanelView::code($bundle->name), - 'Namespace' => $namespace === '' ? self::PLACEHOLDER : $namespace, - 'Source path' => self::orPlaceholder($bundle->sourcePath), - 'Base path' => self::orPlaceholder($bundle->basePath), - 'Base URL' => self::orPlaceholder($bundle->baseUrl), + AssetMessage::CLASS_NAME->value => PanelView::code($bundle->name), + AssetMessage::NAMESPACE_PART->value => $namespace === '' + ? AssetMessage::PLACEHOLDER->value + : $namespace, + AssetMessage::SOURCE_PATH->value => self::orPlaceholder($bundle->sourcePath), + AssetMessage::BASE_PATH->value => self::orPlaceholder($bundle->basePath), + AssetMessage::BASE_URL->value => self::orPlaceholder($bundle->baseUrl), ], true, ); @@ -148,17 +158,17 @@ private static function detail(AssetBundleRow $bundle): PanelView $files = []; foreach ($bundle->css as $file) { - $files[] = [PanelView::badge('css', Tone::INFO), $file]; + $files[] = [PanelView::badge(AssetMessage::CSS_BADGE->value, Tone::INFO), $file]; } foreach ($bundle->js as $file) { - $files[] = [PanelView::badge('js', Tone::WARNING), $file]; + $files[] = [PanelView::badge(AssetMessage::JS_BADGE->value, Tone::WARNING), $file]; } $view = $files === [] - ? $view->paragraph('This bundle declares no CSS or JavaScript files.') + ? $view->paragraph(AssetMessage::NO_FILES->value) : $view->table( - ['Type', 'File'], + [AssetMessage::TYPE->value, AssetMessage::FILE->value], $files, true, [ @@ -177,7 +187,7 @@ private static function detail(AssetBundleRow $bundle): PanelView $rows[] = [$depend]; } - return $view->table(['Depends on'], $rows, true, [0 => ColumnStyle::IDENTIFIER]); + return $view->table([AssetMessage::DEPENDS_ON->value], $rows, true, [0 => ColumnStyle::IDENTIFIER]); } /** @@ -213,7 +223,7 @@ private static function inventory(array $bundles): array */ private static function orPlaceholder(string $value): string { - return $value === '' ? self::PLACEHOLDER : $value; + return $value === '' ? AssetMessage::PLACEHOLDER->value : $value; } /** @@ -229,26 +239,24 @@ private static function vite(PanelView $view, ViteManifest $vite): PanelView $server = $vite->devServerUrl; $mode = match (true) { - $vite->devMode && $server !== null => "Dev server ({$server})", - $vite->devMode => 'Dev server', - default => 'Build manifest', + $vite->devMode && $server !== null => sprintf(AssetMessage::MODE_DEV_SERVER->value, $server), + $vite->devMode => AssetMessage::MODE_DEV->value, + default => AssetMessage::MODE_BUILD->value, }; $view = $view - ->heading('Vite', true) + ->heading(AssetMessage::VITE->value, true) ->overview( [ - 'Mode' => $mode, - 'Base URL' => self::orPlaceholder($vite->baseUrl), - 'Manifest' => self::orPlaceholder($vite->manifestPath), + AssetMessage::MODE->value => $mode, + AssetMessage::BASE_URL->value => self::orPlaceholder($vite->baseUrl), + AssetMessage::MANIFEST->value => self::orPlaceholder($vite->manifestPath), ], true, ); if ($vite->chunks === []) { - return $vite->devMode - ? $view - : $view->paragraph('The Vite manifest is missing or empty; run the front-end build to populate it.'); + return $vite->devMode ? $view : $view->paragraph(AssetMessage::VITE_EMPTY->value); } $rows = []; @@ -260,12 +268,21 @@ private static function vite(PanelView $view, ViteManifest $vite): PanelView self::orPlaceholder($chunk->file), $chunk->cssCount, $chunk->imports, - $chunk->isEntry ? PanelView::badge('entry', Tone::SUCCESS) : self::PLACEHOLDER, + $chunk->isEntry + ? PanelView::badge(AssetMessage::ENTRY_BADGE->value, Tone::SUCCESS) + : AssetMessage::PLACEHOLDER->value, ]; } return $view->table( - ['#', 'Chunk', 'Output', 'CSS', 'Imports', 'Entry'], + [ + AssetMessage::NUMBER->value, + AssetMessage::CHUNK->value, + AssetMessage::OUTPUT->value, + AssetMessage::CSS->value, + AssetMessage::IMPORTS->value, + AssetMessage::ENTRY->value, + ], $rows, true, [ diff --git a/src/Panel/Config/ConfigMessage.php b/src/Panel/Config/ConfigMessage.php new file mode 100644 index 0000000..321f109 --- /dev/null +++ b/src/Panel/Config/ConfigMessage.php @@ -0,0 +1,176 @@ +phpInfoUrl('/debug/php-info') - * ->present($snapshot->jsonSerialize()); - * ``` - * * @phpstan-import-type BadgeInline from PanelView */ final class ConfigPanel extends Panel @@ -33,33 +26,26 @@ final class ConfigPanel extends Panel /** * @var string Icon key shared with the built-in Configuration navigation entry. */ - protected const string ICON = 'config'; - + protected const string ICON = ConfigMessage::ID->value; /** * @var string Stable identifier associating the panel with the captured configuration payload. */ - protected const string ID = 'config'; - + protected const string ID = ConfigMessage::ID->value; /** * @var string Panel title used in the debugger navigation. */ - protected const string TITLE = 'Configuration'; + protected const string TITLE = ConfigMessage::TITLE->value; /** - * @var array Bundled PHP extensions reported as loaded or missing, keyed by payload field. + * @var array Bundled PHP extensions reported as loaded or missing, keyed by payload field. */ private const array PHP_EXTENSIONS = [ - 'xdebug' => 'Xdebug', - 'apcu' => 'APCu', - 'memcache' => 'Memcache', - 'memcached' => 'Memcached', + 'xdebug' => ConfigMessage::PACKAGE_XDEBUG, + 'apcu' => ConfigMessage::PACKAGE_APCU, + 'memcache' => ConfigMessage::PACKAGE_MEMCACHE, + 'memcached' => ConfigMessage::PACKAGE_MEMCACHED, ]; - /** - * @var string Placeholder shown wherever the capture left a field empty. - */ - private const string PLACEHOLDER = '—'; - /** * @var string Adapter-owned phpinfo URL, or `''` when the adapter exposes no phpinfo page. */ @@ -101,17 +87,38 @@ public function present(array $data): PanelView $version = self::text($php, 'version'); $view = PanelView::create() - ->summary('', $yii === '' ? self::PLACEHOLDER : "Yii {$yii}") - ->summary('', $version === '' ? self::PLACEHOLDER : "PHP {$version}", false) - ->summary($count === 1 ? ' extension' : ' extensions', $count) + ->summary( + '', + $yii === '' + ? ConfigMessage::PLACEHOLDER->value + : sprintf(ConfigMessage::YII_SUMMARY->value, $yii), + ) + ->summary( + '', + $version === '' + ? ConfigMessage::PLACEHOLDER->value + : sprintf(ConfigMessage::PHP_SUMMARY->value, $version), + false, + ) + ->summary( + $count === 1 ? ConfigMessage::EXTENSION_SUFFIX->value : ConfigMessage::EXTENSIONS_SUFFIX->value, + $count, + ) ->overview( [ - 'Yii' => self::orPlaceholder($yii), - 'PHP' => self::orPlaceholder($version), - 'Environment' => self::orPlaceholder(self::text($application, 'env')), - 'Debug mode' => self::flag($application, 'debug', 'on', 'off'), - 'Application' => self::orPlaceholder(self::text($application, 'name')), - 'Application version' => self::orPlaceholder(self::text($application, 'version')), + ConfigMessage::YII->value => self::orPlaceholder($yii), + ConfigMessage::PHP->value => self::orPlaceholder($version), + ConfigMessage::ENVIRONMENT->value => self::orPlaceholder(self::text($application, 'env')), + ConfigMessage::DEBUG_MODE->value => self::flag( + $application, + 'debug', + ConfigMessage::DEBUG_ON, + ConfigMessage::DEBUG_OFF, + ), + ConfigMessage::APPLICATION->value => self::orPlaceholder(self::text($application, 'name')), + ConfigMessage::APPLICATION_VERSION->value => self::orPlaceholder( + self::text($application, 'version'), + ), ], true, ); @@ -119,30 +126,37 @@ public function present(array $data): PanelView $runtime = []; foreach (self::PHP_EXTENSIONS as $key => $label) { - $runtime[$label] = self::flag($php, $key, 'loaded', 'missing'); + $runtime[$label->value] = self::flag( + $php, + $key, + ConfigMessage::EXTENSION_LOADED, + ConfigMessage::EXTENSION_MISSING, + ); } $view = $view - ->heading('PHP extensions', true) + ->heading(ConfigMessage::PHP_EXTENSIONS->value, true) ->overview($runtime, true) - ->heading('Application details', true) + ->heading(ConfigMessage::APPLICATION_DETAILS->value, true) ->overview( [ - 'Charset' => self::orPlaceholder(self::text($application, 'charset')), - 'Current language' => self::language(self::text($application, 'language')), - 'Source language' => self::language(self::text($application, 'sourceLanguage')), + ConfigMessage::CHARSET->value => self::orPlaceholder(self::text($application, 'charset')), + ConfigMessage::CURRENT_LANGUAGE->value => self::language(self::text($application, 'language')), + ConfigMessage::SOURCE_LANGUAGE->value => self::language( + self::text($application, 'sourceLanguage'), + ), ], true, ) - ->heading(sprintf('Installed extensions (%d)', $count), true); + ->heading(sprintf(ConfigMessage::INSTALLED->value, $count), true); $view = $count === 0 ? $view->emptyState( - 'No installed extensions recorded', - 'The capture carried no Composer package roster for this request.', + ConfigMessage::EMPTY_HEADLINE->value, + ConfigMessage::EMPTY_EXPLANATION->value, ) : $view->table( - ['Package', 'Version'], + [ConfigMessage::PACKAGE_NAME->value, ConfigMessage::VERSION->value], self::rows($extensions), true, [ @@ -153,7 +167,7 @@ public function present(array $data): PanelView return $this->phpInfoUrl === '' ? $view - : $view->paragraph(PanelView::link('View full phpinfo', $this->phpInfoUrl, true)); + : $view->paragraph(PanelView::link(ConfigMessage::PHP_INFO_LINK->value, $this->phpInfoUrl, true)); } /** @@ -192,16 +206,16 @@ private static function extensions(array $config): array * * @param array $slice Decoded payload slice holding the flag. * @param string $key Flag to read. - * @param string $enabled Badge label used when the flag is `true`. - * @param string $disabled Badge label used when the flag is `false`. + * @param ConfigMessage $enabled Badge label used when the flag is `true`. + * @param ConfigMessage $disabled Badge label used when the flag is `false`. * * @return BadgeInline Success badge when enabled, muted badge otherwise. */ - private static function flag(array $slice, string $key, string $enabled, string $disabled): array + private static function flag(array $slice, string $key, ConfigMessage $enabled, ConfigMessage $disabled): array { return ($slice[$key] ?? false) === true - ? PanelView::badge($enabled, Tone::SUCCESS) - : PanelView::badge($disabled, Tone::MUTED); + ? PanelView::badge($enabled->value, Tone::SUCCESS) + : PanelView::badge($disabled->value, Tone::MUTED); } /** @@ -216,7 +230,7 @@ private static function flag(array $slice, string $key, string $enabled, string private static function language(string $locale): string { if ($locale === '') { - return self::PLACEHOLDER; + return ConfigMessage::PLACEHOLDER->value; } $candidates = [ @@ -232,9 +246,7 @@ private static function language(string $locale): string } } - $annotation = implode(', ', $parts); - - return "{$locale} ({$annotation})"; + return sprintf(ConfigMessage::LANGUAGE_ANNOTATION->value, $locale, implode(', ', $parts)); } /** @@ -246,7 +258,7 @@ private static function language(string $locale): string */ private static function orPlaceholder(string $value): string { - return $value === '' ? self::PLACEHOLDER : $value; + return $value === '' ? ConfigMessage::PLACEHOLDER->value : $value; } /** diff --git a/src/Panel/Event/EventMessage.php b/src/Panel/Event/EventMessage.php index 8558eaf..9bcea18 100644 --- a/src/Panel/Event/EventMessage.php +++ b/src/Panel/Event/EventMessage.php @@ -21,6 +21,11 @@ enum EventMessage: string . 'collector to capture selected context and argument-free source traces. Existing snapshots cannot recover ' . 'missing data. Listeners, their durations, and final propagation results are not captured.'; + /** + * Suffix appended to the distinct event-class count in the summary header. + */ + case CLASSES_SUFFIX = ' classes'; + /** * Status of the context section in the event detail when context was captured. */ @@ -62,6 +67,16 @@ enum EventMessage: string */ case EMPTY_HEADLINE = 'No events dispatched in this request'; + /** + * Header of the event column, also used as its filter label. + */ + case EVENT = 'Event'; + + /** + * Suffix appended to the captured event count in the summary header. + */ + case EVENTS_SUFFIX = ' events'; + /** * Timing label of the time cell for the first observation of the capture. */ @@ -93,6 +108,21 @@ enum EventMessage: string */ case NO_MATCH_HEADLINE = 'No events match the active filters'; + /** + * Header of the position column, which sorts by observation order. + */ + case NUMBER = '#'; + + /** + * Suffix appended to the static-event count in the summary header. + */ + case STATIC_SUFFIX = ' static'; + + /** + * Header of the capture-time column. + */ + case TIME = 'Time'; + /** * Guidance inside the capture coverage disclosure about offsets, gaps, and lifecycle intervals. */ diff --git a/src/Panel/Log/LogMessage.php b/src/Panel/Log/LogMessage.php index b26e949..ff1cf53 100644 --- a/src/Panel/Log/LogMessage.php +++ b/src/Panel/Log/LogMessage.php @@ -5,10 +5,31 @@ namespace PHPForge\Debug\Panel\Log; /** - * Text shown by the Logs panel. + * Presentation text of the Logs panel, shared by every adapter that renders it. */ enum LogMessage: string { + /** + * Header of the log category column, also used as its filter label. + */ + case CATEGORY = 'Category'; + + /** + * `sprintf()` template of the accessible label of a severity chip, naming the count, the plural noun, and the + * severity it filters by. + */ + case CHIP_ARIA = '%d %s; filter log messages by %s level'; + + /** + * `sprintf()` template of the tooltip of a severity chip, naming the severity it filters by. + */ + case CHIP_TITLE = 'Show only %s log messages'; + + /** + * Header of the elapsed-since-previous column. + */ + case DELTA = 'Delta'; + /** * Explanation of the empty state, naming the target that feeds the panel. */ @@ -19,6 +40,71 @@ enum LogMessage: string */ case EMPTY_HEADLINE = 'No log messages captured'; + /** + * Label of the error severity in the level filter. + */ + case FILTER_ERROR = 'Error'; + + /** + * Label of the info severity in the level filter. + */ + case FILTER_INFO = 'Info'; + + /** + * Label of the trace severity in the level filter. + */ + case FILTER_TRACE = 'Trace'; + + /** + * Label of the warning severity in the level filter. + */ + case FILTER_WARNING = 'Warning'; + + /** + * Header of the severity column, also used as its filter label. + */ + case LEVEL = 'Level'; + + /** + * Error severity as named in the chip tooltip and accessible label. + */ + case LEVEL_ERROR = 'error'; + + /** + * Plural noun of the error chip. + */ + case LEVEL_ERRORS = 'errors'; + + /** + * Info severity, used both as the chip noun and as the severity named in its tooltip. + */ + case LEVEL_INFO = 'info'; + + /** + * Trace severity, used both as the chip noun and as the severity named in its tooltip. + */ + case LEVEL_TRACE = 'trace'; + + /** + * Warning severity as named in the chip tooltip and accessible label. + */ + case LEVEL_WARNING = 'warning'; + + /** + * Plural noun of the warning chip. + */ + case LEVEL_WARNINGS = 'warnings'; + + /** + * Header of the log message column, also used as its filter label. + */ + case MESSAGE = 'Message'; + + /** + * Suffix appended to the captured message count in the summary header. + */ + case MESSAGES_SUFFIX = ' messages'; + /** * Explanation of the no-match state, offering the filter reset. */ @@ -28,4 +114,24 @@ enum LogMessage: string * Headline of the no-match state when the active filters exclude every captured message. */ case NO_MATCH_HEADLINE = 'No log messages match the active filters'; + + /** + * Header of the position column, which sorts by capture order. + */ + case NUMBER = '#'; + + /** + * Header of the capture-time column. + */ + case TIME = 'Time'; + + /** + * Label of the toolbar metric counting the captured errors. + */ + case TOOLBAR_ERRORS = 'Errors'; + + /** + * Label of the toolbar metric counting the captured warnings. + */ + case TOOLBAR_WARNINGS = 'Warnings'; } diff --git a/src/Panel/Mail/MailEntry.php b/src/Panel/Mail/MailEntry.php new file mode 100644 index 0000000..a4d4a2b --- /dev/null +++ b/src/Panel/Mail/MailEntry.php @@ -0,0 +1,494 @@ + Blind carbon-copy recipients split out of the comma-separated `bcc` field. + */ + private array $bcc = []; + /** + * Plain-text body as captured, or `''` when the message had no body. + */ + private string $body = ''; + /** + * @var list Carbon-copy recipients split out of the comma-separated `cc` field. + */ + private array $cc = []; + /** + * Charset declared on the message, or `''` when none was set. + */ + private string $charset = ''; + /** + * Path to the persisted `.eml` file, or `''` when the mailer does not expose one. + */ + private string $file = ''; + /** + * Raw RFC-5322 headers as captured by the mailer, joined with line breaks. + */ + private string $headers = ''; + /** + * @var list Reply-to addresses split out of the comma-separated `reply` field. + */ + private array $replyTo = []; + /** + * Capture timestamp as a Unix-epoch second, or `null` when the original payload had no parseable time. + */ + private int|null $time = null; + + /** + * @param string $from Sender address as captured, typically `name@example.com` or `Name `. + * @param list $to Primary recipients, with empty entries dropped. + * @param string $subject Subject line as captured. + * @param bool $isSuccessful `true` when the mailer reported the message as sent, `false` on a reported failure. + */ + private function __construct( + private string $from, + private array $to, + private string $subject, + private bool $isSuccessful, + ) {} + + /** + * Creates a captured message from the envelope fields every mailer reports. + * + * @param string $from Sender address as captured. + * @param list $to Primary recipients, with empty entries dropped. + * @param string $subject Subject line as captured. + * @param bool $isSuccessful Whether the mailer reported the message as sent. + * + * @return self Message carrying the envelope, without body, headers, or capture time. + */ + public static function create(string $from, array $to, string $subject, bool $isSuccessful): self + { + return new self( + $from, + $to, + $subject, + $isSuccessful, + ); + } + + /** + * Counts the messages the mailer rejected. + * + * @param list $models Captured messages. + * + * @return int Number of messages reported as failed. + */ + public static function failedCount(array $models): int + { + $failed = 0; + + foreach ($models as $model) { + if ($model->isSuccessful === false) { + $failed++; + } + } + + return $failed; + } + + /** + * Narrows one persisted message of the mail payload into a typed entry. + * + * @param mixed $data Persisted message, expected to be an object carrying the declared shape. + * @param string $path JSON path of the message, used to report a malformed payload. + * + * @return self Message carrying every persisted field. + */ + public static function fromArray(mixed $data, string $path): self + { + $payload = Payload::object($data, $path) + ->shape( + [ + 'from', + 'to', + 'cc', + 'bcc', + 'replyTo', + 'subject', + 'body', + 'headers', + 'charset', + 'file', + 'isSuccessful', + 'time', + ], + ); + + return self::create( + $payload->string('from'), + Coerce::stringList($payload->list('to')), + $payload->string('subject'), + $payload->bool('isSuccessful'), + ) + ->withBcc(Coerce::stringList($payload->list('bcc'))) + ->withBody($payload->string('body')) + ->withCc(Coerce::stringList($payload->list('cc'))) + ->withCharset($payload->string('charset')) + ->withFile($payload->string('file')) + ->withHeaders($payload->string('headers')) + ->withReplyTo(Coerce::stringList($payload->list('replyTo'))) + ->withTime($payload->nullableInt('time')); + } + + /** + * Narrows one captured `EVENT_AFTER_SEND` payload into a typed message. + * + * @param array $row Captured payload. + * + * @return self Message carrying every field the mailer exposed. + */ + public static function fromCapture(array $row): self + { + return self::create( + self::scalar($row, 'from'), + self::splitAddresses(self::scalar($row, 'to')), + self::scalar($row, 'subject'), + ($row['isSuccessful'] ?? false) === true, + ) + ->withBcc(self::splitAddresses(self::scalar($row, 'bcc'))) + ->withBody(self::scalar($row, 'body')) + ->withCc(self::splitAddresses(self::scalar($row, 'cc'))) + ->withCharset(self::scalar($row, 'charset')) + ->withFile(Coerce::string($row['file'] ?? null)) + ->withHeaders(self::scalar($row, 'headers')) + ->withReplyTo(self::splitAddresses(self::scalar($row, 'reply'))) + ->withTime(self::normalizeTime($row['time'] ?? null)); + } + + /** + * Returns the blind carbon-copy recipients. + * + * @return list Blind carbon-copy recipients in capture order. + */ + public function getBcc(): array + { + return $this->bcc; + } + + /** + * Returns the captured plain-text body. + * + * @return string Plain-text body, or `''` when the message had none. + */ + public function getBody(): string + { + return $this->body; + } + + /** + * Returns the carbon-copy recipients. + * + * @return list Carbon-copy recipients in capture order. + */ + public function getCc(): array + { + return $this->cc; + } + + /** + * Returns the charset declared on the message. + * + * @return string Declared charset, or `''` when none was set. + */ + public function getCharset(): string + { + return $this->charset; + } + + /** + * Returns the path of the message the mailer persisted. + * + * @return string Path to the persisted `.eml` file, or `''` when the mailer exposes none. + */ + public function getFile(): string + { + return $this->file; + } + + /** + * Returns the sender address as captured. + * + * @return string Sender address as captured. + */ + public function getFrom(): string + { + return $this->from; + } + + /** + * Returns the raw message headers as captured. + * + * @return string Raw RFC-5322 headers, joined with line breaks. + */ + public function getHeaders(): string + { + return $this->headers; + } + + /** + * Returns the reply-to addresses. + * + * @return list Reply-to addresses in capture order. + */ + public function getReplyTo(): array + { + return $this->replyTo; + } + + /** + * Returns the subject line as captured. + * + * @return string Subject line as captured. + */ + public function getSubject(): string + { + return $this->subject; + } + + /** + * Returns the capture timestamp. + * + * @return int|null Capture timestamp as a Unix-epoch second, or `null` when the payload carried no parseable time. + */ + public function getTime(): int|null + { + return $this->time; + } + + /** + * Returns the primary recipients. + * + * @return list Primary recipients in capture order. + */ + public function getTo(): array + { + return $this->to; + } + + /** + * Reports whether the mailer delivered the message. + * + * @return bool `true` when the mailer reported the message as sent. + */ + public function isSuccessful(): bool + { + return $this->isSuccessful; + } + + /** + * Serializes the message into its persisted payload. + * + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'from' => $this->from, + 'to' => $this->to, + 'cc' => $this->cc, + 'bcc' => $this->bcc, + 'replyTo' => $this->replyTo, + 'subject' => $this->subject, + 'body' => $this->body, + 'headers' => $this->headers, + 'charset' => $this->charset, + 'file' => $this->file, + 'isSuccessful' => $this->isSuccessful, + 'time' => $this->time, + ]; + } + + /** + * Returns a copy with the blind carbon-copy recipients. + * + * @param list $bcc Recipients in capture order. + * + * @return self New instance carrying the requested recipients. + */ + public function withBcc(array $bcc): self + { + $clone = clone $this; + $clone->bcc = $bcc; + + return $clone; + } + + /** + * Returns a copy with the captured body. + * + * @param string $body Plain-text body, or `''` when the message had none. + * + * @return self New instance carrying the requested body. + */ + public function withBody(string $body): self + { + $clone = clone $this; + $clone->body = $body; + + return $clone; + } + + /** + * Returns a copy with the carbon-copy recipients. + * + * @param list $cc Recipients in capture order. + * + * @return self New instance carrying the requested recipients. + */ + public function withCc(array $cc): self + { + $clone = clone $this; + $clone->cc = $cc; + + return $clone; + } + + /** + * Returns a copy with the declared charset. + * + * @param string $charset Charset declared on the message, or `''` when none was set. + * + * @return self New instance carrying the requested charset. + */ + public function withCharset(string $charset): self + { + $clone = clone $this; + $clone->charset = $charset; + + return $clone; + } + + /** + * Returns a copy with the persisted message file. + * + * @param string $file Path to the `.eml` file, or `''` when the mailer exposes none. + * + * @return self New instance carrying the requested file. + */ + public function withFile(string $file): self + { + $clone = clone $this; + $clone->file = $file; + + return $clone; + } + + /** + * Returns a copy with the raw message headers. + * + * @param string $headers Raw RFC-5322 headers, joined with line breaks. + * + * @return self New instance carrying the requested headers. + */ + public function withHeaders(string $headers): self + { + $clone = clone $this; + $clone->headers = $headers; + + return $clone; + } + + /** + * Returns a copy with the reply-to addresses. + * + * @param list $replyTo Addresses in capture order. + * + * @return self New instance carrying the requested addresses. + */ + public function withReplyTo(array $replyTo): self + { + $clone = clone $this; + $clone->replyTo = $replyTo; + + return $clone; + } + + /** + * Returns a copy with the capture timestamp. + * + * @param int|null $time Unix-epoch second, or `null` when the payload carried no parseable time. + * + * @return self New instance carrying the requested timestamp. + */ + public function withTime(int|null $time): self + { + $clone = clone $this; + $clone->time = $time; + + return $clone; + } + + /** + * Narrows a captured time into a Unix-epoch second. + * + * @param mixed $value Captured time: a `DateTimeInterface`, an `int`, or a `strtotime()`-parseable `string`. + * + * @return int|null Unix-epoch second, or `null` when the value carried no parseable time. + */ + private static function normalizeTime(mixed $value): int|null + { + if ($value instanceof DateTimeInterface) { + return $value->getTimestamp(); + } + + if (is_int($value)) { + return $value; + } + + if (is_string($value) && $value !== '') { + $parsed = strtotime($value); + + return $parsed === false ? null : $parsed; + } + + return null; + } + + /** + * Returns a captured field coerced to a string. + * + * @param array $row Captured payload holding the field. + * @param string $key Field to read. + * + * @return string Captured value, or `''` when missing or not coercible. + */ + private static function scalar(array $row, string $key): string + { + return Coerce::stringOrNull($row[$key] ?? null) ?? ''; + } + + /** + * Splits a comma-separated address list, dropping the empty segments. + * + * @param string $raw Address list as captured. + * + * @return list Trimmed addresses in capture order. + */ + private static function splitAddresses(string $raw): array + { + $parts = array_map(trim(...), explode(',', $raw)); + + return array_values(array_filter($parts, static fn(string $address): bool => $address !== '')); + } +} diff --git a/src/Panel/Mail/MailMessage.php b/src/Panel/Mail/MailMessage.php index d4edc8c..60f960c 100644 --- a/src/Panel/Mail/MailMessage.php +++ b/src/Panel/Mail/MailMessage.php @@ -4,204 +4,183 @@ namespace PHPForge\Debug\Panel\Mail; -use DateTimeInterface; -use PHPForge\Debug\Helper\Coerce; -use PHPForge\Debug\Storage\{PanelRow, Payload}; - -use function array_filter; -use function array_map; -use function array_values; -use function explode; -use function is_int; -use function is_string; -use function strtotime; - /** - * Typed view-model for a single mail message rendered in the Mail panel detail view. + * Identity, formats, and presentation text of the Mail panel, shared by every adapter that renders it. */ -final readonly class MailMessage implements PanelRow +enum MailMessage: string { - public function __construct( - /** - * Sender address as captured (typically `name@example.com` or `Name `). - */ - public string $from, - /** - * @var list Primary recipients split out of the comma-separated `to` field, with empty entries dropped. - */ - public array $to, - /** - * @var list Carbon-copy recipients split out of the comma-separated `cc` field. - */ - public array $cc, - /** - * @var list Blind carbon-copy recipients split out of the comma-separated `bcc` field. - */ - public array $bcc, - /** - * @var list Reply-to addresses split out of the comma-separated `reply` field. - */ - public array $replyTo, - /** - * Subject line as captured. - */ - public string $subject, - /** - * Plain-text body as captured, or `''` when the message had no body. - */ - public string $body, - /** - * Raw RFC-5322 headers as captured by the mailer, joined with line breaks. - */ - public string $headers, - /** - * Charset declared on the message, or `''` when none was set. - */ - public string $charset, - /** - * Path to the persisted `.eml` file, or `''` when the mailer does not expose one. - */ - public string $file, - /** - * `true` when the mailer reported the message as sent, `false` when it reported a failure. - */ - public bool $isSuccessful, - /** - * Capture timestamp as a Unix-epoch second, or `null` when the original payload had no parseable time. - */ - public int|null $time, - ) {} - - /** - * @param list $models Captured messages. - */ - public static function failedCount(array $models): int - { - $failed = 0; - - foreach ($models as $model) { - if ($model->isSuccessful === false) { - $failed++; - } - } - - return $failed; - } - - public static function fromArray(mixed $data, string $path): self - { - $payload = Payload::object($data, $path) - ->shape( - [ - 'from', - 'to', - 'cc', - 'bcc', - 'replyTo', - 'subject', - 'body', - 'headers', - 'charset', - 'file', - 'isSuccessful', - 'time', - ], - ); - - return new self( - from: $payload->string('from'), - to: Coerce::stringList($payload->list('to')), - cc: Coerce::stringList($payload->list('cc')), - bcc: Coerce::stringList($payload->list('bcc')), - replyTo: Coerce::stringList($payload->list('replyTo')), - subject: $payload->string('subject'), - body: $payload->string('body'), - headers: $payload->string('headers'), - charset: $payload->string('charset'), - file: $payload->string('file'), - isSuccessful: $payload->bool('isSuccessful'), - time: $payload->nullableInt('time'), - ); - } - - /** - * Narrows one captured `EVENT_AFTER_SEND` payload into a typed message. - * - * @param array $row Captured payload. - */ - public static function fromCapture(array $row): self - { - return new self( - from: self::scalar($row, 'from'), - to: self::splitAddresses(self::scalar($row, 'to')), - cc: self::splitAddresses(self::scalar($row, 'cc')), - bcc: self::splitAddresses(self::scalar($row, 'bcc')), - replyTo: self::splitAddresses(self::scalar($row, 'reply')), - subject: self::scalar($row, 'subject'), - body: self::scalar($row, 'body'), - headers: self::scalar($row, 'headers'), - charset: self::scalar($row, 'charset'), - file: Coerce::string($row['file'] ?? null), - isSuccessful: ($row['isSuccessful'] ?? false) === true, - time: self::normalizeTime($row['time'] ?? null), - ); - } - - /** - * @return array - */ - public function jsonSerialize(): array - { - return [ - 'from' => $this->from, - 'to' => $this->to, - 'cc' => $this->cc, - 'bcc' => $this->bcc, - 'replyTo' => $this->replyTo, - 'subject' => $this->subject, - 'body' => $this->body, - 'headers' => $this->headers, - 'charset' => $this->charset, - 'file' => $this->file, - 'isSuccessful' => $this->isSuccessful, - 'time' => $this->time, - ]; - } - - private static function normalizeTime(mixed $value): int|null - { - if ($value instanceof DateTimeInterface) { - return $value->getTimestamp(); - } - - if (is_int($value)) { - return $value; - } - - if (is_string($value) && $value !== '') { - $parsed = strtotime($value); - - return $parsed === false ? null : $parsed; - } - - return null; - } - - /** - * @param array $row - */ - private static function scalar(array $row, string $key): string - { - return Coerce::stringOrNull($row[$key] ?? null) ?? ''; - } - - /** - * @return list - */ - private static function splitAddresses(string $raw): array - { - $parts = array_map(trim(...), explode(',', $raw)); - - return array_values(array_filter($parts, static fn(string $address): bool => $address !== '')); - } + /** + * Detail field label of the blind carbon copy recipients. + */ + case BCC = 'Bcc'; + + /** + * Disclosure label of the message body. + */ + case BODY = 'Body'; + + /** + * Detail field label of the carbon copy recipients. + */ + case CC = 'Cc'; + + /** + * Detail field label of the message charset. + */ + case CHARSET = 'Charset'; + + /** + * `date()` format of the absolute timestamp in the detail overview. + */ + case DATE_FORMAT = 'M j, Y · H:i:s'; + + /** + * Suffix appended to a single captured message in the summary header. + */ + case EMAIL_SUFFIX = ' email'; + + /** + * Suffix appended to the captured message count in the summary header. + */ + case EMAILS_SUFFIX = ' emails'; + + /** + * Middle sentence of the empty-state call to action, preceding the mailer call. + */ + case EMPTY_CAPTURE = ' is the capture hook; only requests that call '; + + /** + * Explanation of the empty state when the request dispatched no message. + */ + case EMPTY_EXPLANATION = 'This request did not dispatch any messages through the mailer, so the inbox is empty.'; + + /** + * Headline of the empty state when the mailer captured no message. + */ + case EMPTY_HEADLINE = 'No emails sent in this request'; + + /** + * Capture hook named in the empty state, which records a sent message. + */ + case EMPTY_HOOK = 'BaseMailer::EVENT_AFTER_SEND'; + + /** + * Closing sentence of the empty-state call to action, following the mailer call. + */ + case EMPTY_POPULATE = ' populate this view.'; + + /** + * Mailer call named in the empty state, whose invocation populates the panel. + */ + case EMPTY_SEND = '$mailer->send()'; + + /** + * Suffix appended to the rejected message count in the summary header. + */ + case FAILED_SUFFIX = ' failed'; + + /** + * Header of the sender column, also used as its detail field label. + */ + case FROM = 'From'; + + /** + * Disclosure label of the raw message headers. + */ + case HEADERS = 'Raw headers'; + + /** + * Stable identifier associating the panel with the captured payload, also used as its icon key. + */ + case ID = 'mail'; + + /** + * `sprintf()` template of the detail group label of one message. + */ + case MESSAGE_GROUP = 'Message %d'; + + /** + * `sprintf()` template of the heading preceding each detail group. + */ + case MESSAGE_HEADING = '%d. %s'; + + /** + * Note of the detail group when the mailer captured no body. + */ + case NO_BODY = 'The mailer captured no body for this message.'; + + /** + * Header of the position column, which sorts by send order. + */ + case NUMBER = '#'; + + /** + * Placeholder shown wherever the capture left a field empty. + */ + case PLACEHOLDER = '—'; + + /** + * Detail field label of the reply-to recipients. + */ + case REPLY_TO = 'Reply-To'; + + /** + * Detail field label of the absolute send time. + */ + case SENT_AT = 'Sent at'; + + /** + * Header of the delivery status column, also used as its detail field label. + */ + case STATUS = 'Status'; + + /** + * Badge label of a message the mailer rejected. + */ + case STATUS_FAILED = 'Failed'; + + /** + * Badge label of a message the mailer delivered. + */ + case STATUS_SENT = 'Sent'; + + /** + * Detail field label of the stored message file. + */ + case STORED_FILE = 'Stored file'; + + /** + * Header of the subject column, also used as its detail field label. + */ + case SUBJECT = 'Subject'; + + /** + * Subject shown when the mailer captured none. + */ + case SUBJECT_FALLBACK = '(no subject)'; + + /** + * Header of the send-time column. + */ + case TIME = 'Time'; + + /** + * `date()` format of the clock-only timestamp in the summary table. + */ + case TIME_FORMAT = 'H:i:s'; + + /** + * Panel title used in the debugger navigation. + */ + case TITLE = 'Mail'; + + /** + * Header of the recipient column, also used as its detail field label. + */ + case TO = 'To'; + + /** + * Label of the toolbar metric counting the captured messages. + */ + case TOOLBAR = 'Emails'; } diff --git a/src/Panel/Mail/MailPanel.php b/src/Panel/Mail/MailPanel.php index df1c2e9..ed527e8 100644 --- a/src/Panel/Mail/MailPanel.php +++ b/src/Panel/Mail/MailPanel.php @@ -21,37 +21,15 @@ final class MailPanel extends Panel /** * @var string Icon key shared with the built-in Mail navigation entry. */ - protected const string ICON = 'mail'; - + protected const string ICON = MailMessage::ID->value; /** * @var string Stable identifier associating the panel with the captured mail payload. */ - protected const string ID = 'mail'; - + protected const string ID = MailMessage::ID->value; /** * @var string Panel title used in the debugger navigation. */ - protected const string TITLE = 'Mail'; - - /** - * @var string Absolute timestamp format of the detail overview. - */ - private const string DATE_FORMAT = 'M j, Y · H:i:s'; - - /** - * @var string Placeholder shown wherever the capture left a field empty. - */ - private const string PLACEHOLDER = '—'; - - /** - * @var string Subject shown when the mailer captured none. - */ - private const string SUBJECT_FALLBACK = '(no subject)'; - - /** - * @var string Clock-only timestamp format of the summary table. - */ - private const string TIME_FORMAT = 'H:i:s'; + protected const string TITLE = MailMessage::TITLE->value; /** * Builds the panel view from the decoded mail capture. @@ -70,29 +48,39 @@ public function present(array $data): PanelView if ($count === 0) { return $view->emptyState( - 'No emails sent in this request', - 'This request did not dispatch any messages through the mailer, so the inbox is empty.', + MailMessage::EMPTY_HEADLINE->value, + MailMessage::EMPTY_EXPLANATION->value, [ - PanelView::code('BaseMailer::EVENT_AFTER_SEND'), - ' is the capture hook; only requests that call ', - PanelView::code('$mailer->send()'), - ' populate this view.', + PanelView::code(MailMessage::EMPTY_HOOK->value), + MailMessage::EMPTY_CAPTURE->value, + PanelView::code(MailMessage::EMPTY_SEND->value), + MailMessage::EMPTY_POPULATE->value, ], ); } - $failed = MailMessage::failedCount($messages); + $failed = MailEntry::failedCount($messages); $view = $view - ->summary($count === 1 ? ' email' : ' emails', $count) - ->toolbar('Emails', $count); + ->summary( + $count === 1 ? MailMessage::EMAIL_SUFFIX->value : MailMessage::EMAILS_SUFFIX->value, + $count, + ) + ->toolbar(MailMessage::TOOLBAR->value, $count); if ($failed > 0) { - $view = $view->summary(' failed', $failed); + $view = $view->summary(MailMessage::FAILED_SUFFIX->value, $failed); } $view = $view->table( - ['#', 'From', 'Subject', 'To', 'Status', 'Time'], + [ + MailMessage::NUMBER->value, + MailMessage::FROM->value, + MailMessage::SUBJECT->value, + MailMessage::TO->value, + MailMessage::STATUS->value, + MailMessage::TIME->value, + ], self::rows($messages), styles: [ 0 => ColumnStyle::NUMBER, @@ -105,8 +93,14 @@ public function present(array $data): PanelView $position = $index + 1; $view = $view - ->heading(sprintf('%d. %s', $position, self::subject($message)), true) - ->group(sprintf('Message %d', $position), self::detail($message)); + ->heading( + sprintf(MailMessage::MESSAGE_HEADING->value, $position, self::subject($message)), + true, + ) + ->group( + sprintf(MailMessage::MESSAGE_GROUP->value, $position), + self::detail($message), + ); } return $view; @@ -121,60 +115,64 @@ public function present(array $data): PanelView */ private static function addresses(array $addresses): string { - return $addresses === [] ? self::PLACEHOLDER : implode(', ', $addresses); + return $addresses === [] ? MailMessage::PLACEHOLDER->value : implode(', ', $addresses); } /** * Builds the detail group of one message: envelope overview, body, and raw headers. * - * @param MailMessage $message Captured message to describe. + * @param MailEntry $message Captured message to describe. * * @return PanelView Child view holding only the detail blocks of the message. */ - private static function detail(MailMessage $message): PanelView + private static function detail(MailEntry $message): PanelView { $fields = [ - 'From' => $message->from === '' ? self::PLACEHOLDER : $message->from, - 'To' => self::addresses($message->to), + MailMessage::FROM->value => $message->getFrom() === '' + ? MailMessage::PLACEHOLDER->value + : $message->getFrom(), + MailMessage::TO->value => self::addresses($message->getTo()), ]; - if ($message->cc !== []) { - $fields['Cc'] = self::addresses($message->cc); + if ($message->getCc() !== []) { + $fields[MailMessage::CC->value] = self::addresses($message->getCc()); } - if ($message->bcc !== []) { - $fields['Bcc'] = self::addresses($message->bcc); + if ($message->getBcc() !== []) { + $fields[MailMessage::BCC->value] = self::addresses($message->getBcc()); } - if ($message->replyTo !== []) { - $fields['Reply-To'] = self::addresses($message->replyTo); + if ($message->getReplyTo() !== []) { + $fields[MailMessage::REPLY_TO->value] = self::addresses($message->getReplyTo()); } - $fields['Subject'] = self::subject($message); - $fields['Status'] = self::status($message); - $fields['Sent at'] = self::timestamp($message, self::DATE_FORMAT); + $fields[MailMessage::SUBJECT->value] = self::subject($message); + $fields[MailMessage::STATUS->value] = self::status($message); + $fields[MailMessage::SENT_AT->value] = self::timestamp($message, MailMessage::DATE_FORMAT); - if ($message->charset !== '') { - $fields['Charset'] = $message->charset; + if ($message->getCharset() !== '') { + $fields[MailMessage::CHARSET->value] = $message->getCharset(); } - if ($message->file !== '') { - $fields['Stored file'] = PanelView::code($message->file); + if ($message->getFile() !== '') { + $fields[MailMessage::STORED_FILE->value] = PanelView::code($message->getFile()); } $view = PanelView::create()->overview($fields, true); - $view = $message->body === '' - ? $view->callout(Tone::MUTED, 'The mailer captured no body for this message.') - : $view->disclosure('Body', $message->body); + $view = $message->getBody() === '' + ? $view->callout(Tone::MUTED, MailMessage::NO_BODY->value) + : $view->disclosure(MailMessage::BODY->value, $message->getBody()); - return $message->headers === '' ? $view : $view->disclosure('Raw headers', $message->headers); + return $message->getHeaders() === '' + ? $view + : $view->disclosure(MailMessage::HEADERS->value, $message->getHeaders()); } /** * Builds the summary table rows in capture order. * - * @param list $messages Captured messages in send order. + * @param list $messages Captured messages in send order. * * @return list> One row per message, matching the declared column order. */ @@ -185,11 +183,11 @@ private static function rows(array $messages): array foreach ($messages as $index => $message) { $rows[] = [ $index + 1, - $message->from === '' ? self::PLACEHOLDER : $message->from, + $message->getFrom() === '' ? MailMessage::PLACEHOLDER->value : $message->getFrom(), PanelView::strong(self::subject($message)), - self::addresses($message->to), + self::addresses($message->getTo()), self::status($message), - self::timestamp($message, self::TIME_FORMAT), + self::timestamp($message, MailMessage::TIME_FORMAT), ]; } @@ -199,39 +197,41 @@ private static function rows(array $messages): array /** * Builds the delivery badge reported by the mailer. * - * @param MailMessage $message Captured message to describe. + * @param MailEntry $message Captured message to describe. * * @return BadgeInline Success badge for a delivered message, danger badge for a rejected one. */ - private static function status(MailMessage $message): array + private static function status(MailEntry $message): array { - return $message->isSuccessful - ? PanelView::badge('Sent', Tone::SUCCESS) - : PanelView::badge('Failed', Tone::DANGER); + return $message->isSuccessful() + ? PanelView::badge(MailMessage::STATUS_SENT->value, Tone::SUCCESS) + : PanelView::badge(MailMessage::STATUS_FAILED->value, Tone::DANGER); } /** * Returns the captured subject, falling back to an explicit placeholder when the mailer captured none. * - * @param MailMessage $message Captured message to describe. + * @param MailEntry $message Captured message to describe. * * @return string Captured subject, or the subject fallback when empty. */ - private static function subject(MailMessage $message): string + private static function subject(MailEntry $message): string { - return $message->subject === '' ? self::SUBJECT_FALLBACK : $message->subject; + return $message->getSubject() === '' ? MailMessage::SUBJECT_FALLBACK->value : $message->getSubject(); } /** * Formats the capture time, falling back to the placeholder when the payload carried no parseable time. * - * @param MailMessage $message Captured message to describe. - * @param string $format Date format applied to the capture timestamp. + * @param MailEntry $message Captured message to describe. + * @param MailMessage $format Date format applied to the capture timestamp. * * @return string Formatted timestamp, or the placeholder when the message has no time. */ - private static function timestamp(MailMessage $message, string $format): string + private static function timestamp(MailEntry $message, MailMessage $format): string { - return $message->time === null ? self::PLACEHOLDER : date($format, $message->time); + return $message->getTime() === null + ? MailMessage::PLACEHOLDER->value + : date($format->value, $message->getTime()); } } diff --git a/src/Panel/Mail/MailSnapshot.php b/src/Panel/Mail/MailSnapshot.php index 283709c..8b3863e 100644 --- a/src/Panel/Mail/MailSnapshot.php +++ b/src/Panel/Mail/MailSnapshot.php @@ -15,7 +15,7 @@ final readonly class MailSnapshot implements PanelSnapshot { /** - * @param list $entries + * @param list $entries Captured messages in send order. */ public function __construct(private array $entries) {} @@ -23,6 +23,8 @@ public function __construct(private array $entries) {} * Narrows the captured `EVENT_AFTER_SEND` payloads into typed messages. * * @param array $messages Captured payloads in send order; non-array entries are dropped. + * + * @return self Snapshot carrying the typed messages. */ public static function capture(array $messages): self { @@ -30,7 +32,7 @@ public static function capture(array $messages): self foreach ($messages as $message) { if (is_array($message)) { - $entries[] = MailMessage::fromCapture($message); + $entries[] = MailEntry::fromCapture($message); } } @@ -38,29 +40,41 @@ public static function capture(array $messages): self } /** - * @return list Captured messages in send order. + * Returns the captured messages. + * + * @return list Captured messages in send order. */ public function entries(): array { return $this->entries; } + /** + * Narrows the persisted mail payload into a typed snapshot. + * + * @param mixed $data Persisted payload, expected to be an object carrying an `entries` list. + * @param string $path JSON path of the payload, used to report a malformed capture. + * + * @return self Snapshot carrying the persisted messages. + */ public static function fromArray(mixed $data, string $path): self { return new self( Payload::object($data, $path) ->shape(['entries']) - ->mapList('entries', MailMessage::fromArray(...)), + ->mapList('entries', MailEntry::fromArray(...)), ); } /** - * @return array + * Serializes the snapshot into its persisted payload. + * + * @return array Payload carrying the messages under the `entries` key. */ public function jsonSerialize(): array { return [ - 'entries' => array_map(static fn(MailMessage $row): array => $row->jsonSerialize(), $this->entries), + 'entries' => array_map(static fn(MailEntry $row): array => $row->jsonSerialize(), $this->entries), ]; } } diff --git a/src/Panel/Profile/ProfileMessage.php b/src/Panel/Profile/ProfileMessage.php index 9b73045..169d9a7 100644 --- a/src/Panel/Profile/ProfileMessage.php +++ b/src/Panel/Profile/ProfileMessage.php @@ -5,10 +5,30 @@ namespace PHPForge\Debug\Panel\Profile; /** - * Text shown by the Profiling panel. + * Presentation text of the Profiling panel, shared by every adapter that renders it. */ enum ProfileMessage: string { + /** + * Label of the filter form submit button. + */ + case APPLY = 'Apply'; + + /** + * Label of the category filter. + */ + case CATEGORY = 'Category'; + + /** + * Placeholder of the category filter, showing the category of an instrumented database query. + */ + case CATEGORY_PLACEHOLDER = 'yii\db\Command::query'; + + /** + * Heading above the captured span table. + */ + case DETAILS = 'Details'; + /** * Call to action of the empty state, introducing the profile marker example. */ @@ -30,6 +50,41 @@ enum ProfileMessage: string */ case EMPTY_HEADLINE = 'No profiling data captured'; + /** + * Closing sentence of the empty state, following the profile markers it names. + */ + case EMPTY_NO_SPANS = ' spans, so the Timeline and details are empty.'; + + /** + * Opening sentence of the empty state, preceding the profile markers it names. + */ + case EMPTY_PRODUCED = 'This request did not produce any '; + + /** + * Separator between the two profile markers named in the empty state. + */ + case EMPTY_SEPARATOR = ' / '; + + /** + * Accessible label of the filter form. + */ + case FILTERS = 'Profiling filters'; + + /** + * Label of the info filter. + */ + case INFO = 'Info'; + + /** + * Placeholder of the info filter, showing the leading verb of an instrumented statement. + */ + case INFO_PLACEHOLDER = 'SELECT'; + + /** + * Label of the minimum-duration filter. + */ + case MIN_DURATION = 'Min duration (ms)'; + /** * Explanation of the no-match state, offering the filter reset. */ @@ -40,6 +95,21 @@ enum ProfileMessage: string */ case NO_MATCH_HEADLINE = 'No spans match the active filters'; + /** + * Suffix appended to the peak memory in the summary header. + */ + case PEAK_SUFFIX = ' peak'; + + /** + * Suffix appended to a single captured span in the summary header. + */ + case SPAN_SUFFIX = ' span'; + + /** + * Suffix appended to the captured span count in the summary header. + */ + case SPANS_SUFFIX = ' spans'; + /** * Closing note of the timeline fallback, pointing at the profiling details below the chart. */ @@ -55,4 +125,19 @@ enum ProfileMessage: string * Headline of the timeline fallback when the capture cannot position the chart. */ case TIMELINE_UNAVAILABLE_HEADLINE = 'Timeline unavailable'; + + /** + * Title of the toolbar chip reporting the peak memory. + */ + case TOOLBAR_MEMORY = 'Peak memory'; + + /** + * Title of the toolbar chip reporting the total processing time. + */ + case TOOLBAR_TIME = 'Total processing time'; + + /** + * Suffix appended to the total processing time in the summary header. + */ + case TOTAL_SUFFIX = ' total'; } diff --git a/src/Panel/Queue/QueueMessage.php b/src/Panel/Queue/QueueMessage.php new file mode 100644 index 0000000..57dd6d5 --- /dev/null +++ b/src/Panel/Queue/QueueMessage.php @@ -0,0 +1,252 @@ +value; /** * @var string Stable identifier associating the panel with the captured queue payload. */ - protected const string ID = 'queue'; - + protected const string ID = QueueMessage::ID->value; /** * @var string Panel title used in the debugger navigation. */ - protected const string TITLE = 'Queue'; + protected const string TITLE = QueueMessage::TITLE->value; /** - * @var string Placeholder shown wherever the capture left a field empty. - */ - private const string PLACEHOLDER = '—'; - - /** - * @var array Badge describing each captured lifecycle phase. + * @var array Badge describing each captured lifecycle phase. */ private const array STATUS = [ - JobRecord::TYPE_PUSH => ['label' => 'Queued', 'tone' => Tone::INFO], - JobRecord::TYPE_EXEC => ['label' => 'Done', 'tone' => Tone::SUCCESS], - JobRecord::TYPE_ERROR => ['label' => 'Failed', 'tone' => Tone::DANGER], + JobRecord::TYPE_PUSH => ['label' => QueueMessage::STATUS_QUEUED, 'tone' => Tone::INFO], + JobRecord::TYPE_EXEC => ['label' => QueueMessage::STATUS_DONE, 'tone' => Tone::SUCCESS], + JobRecord::TYPE_ERROR => ['label' => QueueMessage::STATUS_FAILED, 'tone' => Tone::DANGER], ]; /** @@ -87,26 +80,29 @@ public function present(array $data): PanelView $view = PanelView::create() ->active($total > 0) - ->summary($total === 1 ? ' event' : ' events', $total) - ->summary(' queued', $summary->totalPushed()) - ->summary(' done', $summary->totalExecuted()) - ->toolbar('Jobs', $total); + ->summary( + $total === 1 ? QueueMessage::EVENT_SUFFIX->value : QueueMessage::EVENTS_SUFFIX->value, + $total, + ) + ->summary(QueueMessage::QUEUED_SUFFIX->value, $summary->totalPushed()) + ->summary(QueueMessage::DONE_SUFFIX->value, $summary->totalExecuted()) + ->toolbar(QueueMessage::TOOLBAR->value, $total); if ($errors > 0) { - $view = $view->summary(' failed', $errors); + $view = $view->summary(QueueMessage::FAILED_SUFFIX->value, $errors); } if ($total === 0) { return $view->emptyState( - 'No queue activity in this request', - 'This request pushed no job and ran none, so the lifecycle log is empty.', + QueueMessage::EMPTY_HEADLINE->value, + QueueMessage::EMPTY_EXPLANATION->value, [ - 'Events appear here when a queue component emits ', - PanelView::code('afterPush'), + QueueMessage::EMPTY_HOOKS->value, + PanelView::code(QueueMessage::HOOK_AFTER_PUSH->value), ', ', - PanelView::code('afterExec'), + PanelView::code(QueueMessage::HOOK_AFTER_EXEC->value), ', or ', - PanelView::code('afterError'), + PanelView::code(QueueMessage::HOOK_AFTER_ERROR->value), '.', ], ); @@ -117,17 +113,26 @@ public function present(array $data): PanelView if ($async !== []) { $view = $view->callout( Tone::INFO, - PanelView::strong('Async driver: ' . implode(', ', $async) . '.'), - ' Push events show here, but jobs run in a separate worker process; see the History sidebar for ', - PanelView::strong('CLI'), - ' debug snapshots that capture the matching exec and error events.', + PanelView::strong(QueueMessage::ASYNC_TITLE->value . implode(', ', $async) . '.'), + QueueMessage::ASYNC_WORKER->value, + PanelView::strong(QueueMessage::CLI->value), + QueueMessage::ASYNC_SNAPSHOTS->value, ); } $view = $view - ->heading('Lifecycle events', true) + ->heading(QueueMessage::LIFECYCLE->value, true) ->table( - ['#', 'Status', 'Job', 'Component', 'Driver', 'Time', 'Attempt', 'Duration'], + [ + QueueMessage::NUMBER->value, + QueueMessage::STATUS->value, + QueueMessage::JOB->value, + QueueMessage::COMPONENT->value, + QueueMessage::DRIVER->value, + QueueMessage::TIME->value, + QueueMessage::ATTEMPT->value, + QueueMessage::DURATION->value, + ], $this->rows($records), true, [ @@ -144,8 +149,14 @@ public function present(array $data): PanelView foreach ($records as $index => $record) { $view = $view - ->heading(sprintf('%d. %s', $index + 1, self::jobClass($record)), true) - ->group(sprintf('Event %d', $index + 1), $this->detail($record, $index)); + ->heading( + sprintf(QueueMessage::RECORD_HEADING->value, $index + 1, self::jobClass($record)), + true, + ) + ->group( + sprintf(QueueMessage::EVENT_GROUP->value, $index + 1), + $this->detail($record, $index), + ); } return $view; @@ -163,7 +174,11 @@ private static function asyncDrivers(array $records): array $drivers = []; foreach ($records as $record) { - if ($record->isAsync && $record->driverName !== '' && in_array($record->driverName, $drivers, true) === false) { + if ( + $record->isAsync + && $record->driverName !== '' + && in_array($record->driverName, $drivers, true) === false + ) { $drivers[] = $record->driverName; } } @@ -182,28 +197,30 @@ private static function asyncDrivers(array $records): array private function detail(JobRecord $record, int $index): PanelView { $fields = [ - 'Job' => PanelView::code(self::jobClass($record)), - 'Status' => self::status($record), - 'Component' => self::orPlaceholder($record->componentId), - 'Driver' => self::orPlaceholder($record->driverName), - 'Driver class' => $record->driverClass === '' - ? self::PLACEHOLDER + QueueMessage::JOB->value => PanelView::code(self::jobClass($record)), + QueueMessage::STATUS->value => self::status($record), + QueueMessage::COMPONENT->value => self::orPlaceholder($record->componentId), + QueueMessage::DRIVER->value => self::orPlaceholder($record->driverName), + QueueMessage::DRIVER_CLASS->value => $record->driverClass === '' + ? QueueMessage::PLACEHOLDER->value : PanelView::code($record->driverClass), - 'Execution' => $record->isAsync ? 'Worker process' : 'In process', - 'Job id' => self::orPlaceholder($record->jobId), - 'Pushed at' => date('M j, Y · H:i:s', (int) $record->time), - 'TTR' => self::seconds($record->ttr), - 'Delay' => self::seconds($record->delay), - 'Priority' => $record->priority === null ? self::PLACEHOLDER : $record->priority, - 'Attempt' => $record->attempt === null ? self::PLACEHOLDER : $record->attempt, - 'Duration' => self::duration($record->duration), + QueueMessage::EXECUTION->value => $record->isAsync + ? QueueMessage::WORKER_PROCESS->value + : QueueMessage::IN_PROCESS->value, + QueueMessage::JOB_ID->value => self::orPlaceholder($record->jobId), + QueueMessage::PUSHED_AT->value => date(QueueMessage::DATE_FORMAT->value, (int) $record->time), + QueueMessage::TTR->value => self::seconds($record->ttr), + QueueMessage::DELAY->value => self::seconds($record->delay), + QueueMessage::PRIORITY->value => $record->priority ?? QueueMessage::PLACEHOLDER->value, + QueueMessage::ATTEMPT->value => $record->attempt ?? QueueMessage::PLACEHOLDER->value, + QueueMessage::DURATION->value => self::duration($record->duration), ]; $url = $this->jobUrls[$index] ?? null; if ($url !== null) { - $fields['Details'] = PanelView::link( - 'Open job detail', + $fields[QueueMessage::DETAILS->value] = PanelView::link( + QueueMessage::JOB_LINK->value, $url, ); } @@ -215,8 +232,8 @@ private function detail(JobRecord $record, int $index): PanelView } return $record->payloadFields === [] - ? $view->paragraph('The event carried no job payload.') - : $view->overview(['Payload' => PanelView::value($record->payloadFields)]); + ? $view->paragraph(QueueMessage::NO_PAYLOAD->value) + : $view->overview([QueueMessage::PAYLOAD->value => PanelView::value($record->payloadFields)]); } /** @@ -228,7 +245,7 @@ private function detail(JobRecord $record, int $index): PanelView */ private static function duration(float|null $duration): string { - return $duration === null ? self::PLACEHOLDER : Format::milliseconds($duration, 1); + return $duration === null ? QueueMessage::PLACEHOLDER->value : Format::milliseconds($duration, 1); } /** @@ -240,7 +257,7 @@ private static function duration(float|null $duration): string */ private static function jobClass(JobRecord $record): string { - return $record->jobClass === '' ? self::PLACEHOLDER : $record->jobClass; + return $record->jobClass === '' ? QueueMessage::PLACEHOLDER->value : $record->jobClass; } /** @@ -252,7 +269,7 @@ private static function jobClass(JobRecord $record): string */ private static function orPlaceholder(string $value): string { - return $value === '' ? self::PLACEHOLDER : $value; + return $value === '' ? QueueMessage::PLACEHOLDER->value : $value; } /** @@ -273,8 +290,8 @@ private function rows(array $records): array self::jobClass($record), self::orPlaceholder($record->componentId), self::orPlaceholder($record->driverName), - date('H:i:s', (int) $record->time), - $record->attempt === null ? self::PLACEHOLDER : $record->attempt, + date(QueueMessage::TIME_FORMAT->value, (int) $record->time), + $record->attempt ?? QueueMessage::PLACEHOLDER->value, self::duration($record->duration), ]; } @@ -291,7 +308,7 @@ private function rows(array $records): array */ private static function seconds(int|null $value): string { - return $value === null ? self::PLACEHOLDER : "{$value}s"; + return $value === null ? QueueMessage::PLACEHOLDER->value : "{$value}s"; } /** @@ -308,7 +325,7 @@ private static function status(JobRecord $record): array $status = self::STATUS[$record->eventType] ?? self::STATUS[JobRecord::TYPE_PUSH]; return PanelView::badge( - $status['label'], + $status['label']->value, $status['tone'], ); } diff --git a/src/Panel/Request/RequestDataNormalizer.php b/src/Panel/Request/RequestDataNormalizer.php index 46d2395..432fe2e 100644 --- a/src/Panel/Request/RequestDataNormalizer.php +++ b/src/Panel/Request/RequestDataNormalizer.php @@ -22,10 +22,10 @@ final class RequestDataNormalizer * @var array Boolean flags surfaced as chips on the hero meta strip, in display order. */ private const array FLAG_LABELS = [ - 'isAjax' => 'AJAX', - 'isPjax' => 'PJAX', - 'isFlash' => 'Flash', - 'isSecureConnection' => 'HTTPS', + 'isAjax' => RequestMessage::AJAX->value, + 'isPjax' => RequestMessage::PJAX->value, + 'isFlash' => RequestMessage::FLASH->value, + 'isSecureConnection' => RequestMessage::HTTPS->value, ]; /** @@ -103,13 +103,21 @@ private static function buildHero(array $data, RequestSummary|null $summary): Re private static function buildTabs(array $data): array { $tabs = [ - new RequestTab(label: 'Parameters', sections: self::parameterSections($data), id: 'parameters'), - new RequestTab(label: 'Headers', sections: self::headerSections($data), id: 'headers'), + new RequestTab( + label: RequestMessage::PARAMETERS->value, + sections: self::parameterSections($data), + id: 'parameters', + ), + new RequestTab( + label: RequestMessage::HEADERS->value, + sections: self::headerSections($data), + id: 'headers', + ), ]; if (array_key_exists('SESSION', $data) && array_key_exists('flashes', $data)) { $tabs[] = new RequestTab( - label: 'Session', + label: RequestMessage::SESSION->value, sections: self::sessionSections($data), id: 'session', ); @@ -117,10 +125,10 @@ private static function buildTabs(array $data): array if (array_key_exists('SERVER', $data)) { $tabs[] = new RequestTab( - label: 'Server', + label: RequestMessage::SERVER->value, sections: [ new RequestSection( - caption: 'Server', + caption: RequestMessage::SERVER->value, entries: self::asEntries($data['SERVER']), filterable: true, id: 'server', @@ -144,13 +152,13 @@ private static function headerSections(array $data): array { return [ new RequestSection( - caption: 'Request Headers', + caption: RequestMessage::REQUEST_HEADERS_CAPTION->value, entries: self::asEntries($data['requestHeaders'] ?? []), filterable: true, id: 'request-headers', ), new RequestSection( - caption: 'Response Headers', + caption: RequestMessage::RESPONSE_HEADERS_CAPTION->value, entries: self::asEntries($data['responseHeaders'] ?? []), filterable: true, id: 'response-headers', @@ -169,11 +177,11 @@ private static function parameterSections(array $data): array { $sections = [ new RequestSection( - caption: 'Routing', + caption: RequestMessage::ROUTING->value, entries: [ - 'Route' => $data['route'] ?? null, - 'Action' => $data['action'] ?? null, - 'Parameters' => $data['actionParams'] ?? null, + RequestMessage::ROUTE->value => $data['route'] ?? null, + RequestMessage::ACTION->value => $data['action'] ?? null, + RequestMessage::PARAMETERS->value => $data['actionParams'] ?? null, ], id: 'routing', ), @@ -181,7 +189,7 @@ private static function parameterSections(array $data): array if (array_key_exists('GET', $data)) { $sections[] = new RequestSection( - caption: 'Get', + caption: RequestMessage::GET->value, entries: self::asEntries($data['GET']), filterable: true, id: 'get', @@ -190,7 +198,7 @@ private static function parameterSections(array $data): array if (array_key_exists('POST', $data)) { $sections[] = new RequestSection( - caption: 'Post', + caption: RequestMessage::POST->value, entries: self::asEntries($data['POST']), filterable: true, id: 'post', @@ -199,7 +207,7 @@ private static function parameterSections(array $data): array if (array_key_exists('FILES', $data)) { $sections[] = new RequestSection( - caption: 'Files', + caption: RequestMessage::FILES->value, entries: self::asEntries($data['FILES']), filterable: true, id: 'files', @@ -208,7 +216,7 @@ private static function parameterSections(array $data): array if (array_key_exists('COOKIE', $data)) { $sections[] = new RequestSection( - caption: 'Cookies', + caption: RequestMessage::COOKIES->value, entries: self::asEntries($data['COOKIE']), filterable: true, id: 'cookies', @@ -216,7 +224,7 @@ private static function parameterSections(array $data): array } $sections[] = new RequestSection( - caption: 'Request Body', + caption: RequestMessage::REQUEST_BODY->value, entries: self::asEntries($data['requestBody'] ?? []), filterable: true, id: 'request-body', @@ -236,13 +244,13 @@ private static function sessionSections(array $data): array { return [ new RequestSection( - caption: 'Session', + caption: RequestMessage::SESSION->value, entries: self::asEntries($data['SESSION'] ?? []), filterable: true, id: 'session', ), new RequestSection( - caption: 'Flashes', + caption: RequestMessage::FLASHES->value, entries: self::asEntries($data['flashes'] ?? []), filterable: true, id: 'flashes', @@ -255,8 +263,6 @@ private static function sessionSections(array $data): array */ private static function statusVariant(int $statusCode): string { - return Vocabulary::statusClass( - $statusCode, - ); + return Vocabulary::statusClass($statusCode); } } diff --git a/src/Panel/Request/RequestHeadersRenderer.php b/src/Panel/Request/RequestHeadersRenderer.php index 164ae89..3106328 100644 --- a/src/Panel/Request/RequestHeadersRenderer.php +++ b/src/Panel/Request/RequestHeadersRenderer.php @@ -20,8 +20,12 @@ final class RequestHeadersRenderer { /** - * @param array $request - * @param array $response + * Renders the request and response headers side by side, with a shared filter above both lanes. + * + * @param array $request Captured request headers, keyed by header name. + * @param array $response Captured response headers, keyed by header name. + * + * @return string Header exchange markup. */ public static function render(array $request, array $response): string { @@ -35,7 +39,7 @@ public static function render(array $request, array $response): string ->html( H2::tag() ->id('yii-debug-header-exchange-title') - ->content('Header exchange'), + ->content(RequestMessage::HEADER_EXCHANGE->value), Div::tag() ->class('yii-debug-diagnostic-counts') ->html( @@ -48,7 +52,7 @@ public static function render(array $request, array $response): string if ($total > 0) { $headerChildren[] = InputSearch::tag() - ->addAriaAttribute('label', 'Filter request and response headers') + ->addAriaAttribute('label', RequestMessage::HEADERS_FILTER->value) ->addDataAttribute('yii-debug-filter', true) ->class('yii-debug-filter-input yii-debug-diagnostic-filter') ->placeholder('Filter headers…'); @@ -71,14 +75,14 @@ public static function render(array $request, array $response): string ->html( self::renderLane( id: 'request', - direction: 'Inbound', - title: 'Request headers', + direction: RequestMessage::INBOUND->value, + title: RequestMessage::REQUEST_HEADERS_TITLE->value, entries: $request, ), self::renderLane( id: 'response', - direction: 'Outbound', - title: 'Response headers', + direction: RequestMessage::OUTBOUND->value, + title: RequestMessage::RESPONSE_HEADERS_TITLE->value, entries: $response, ), ), @@ -92,6 +96,14 @@ public static function render(array $request, array $response): string ->render(); } + /** + * Renders the header count of one lane, annotated with its direction. + * + * @param int $count Number of headers captured in the lane. + * @param string $direction Direction of the lane, shown after the count. + * + * @return Span Count element of the lane. + */ private static function renderCount(int $count, string $direction): Span { return Span::tag() @@ -105,7 +117,14 @@ private static function renderCount(int $count, string $direction): Span } /** - * @param array $entries + * Renders one direction of the exchange, replacing its ledger with a note when no header was captured. + * + * @param string $id Identifier of the lane, used to associate it with the filter. + * @param string $direction Direction of the lane, shown next to its count. + * @param string $title Heading of the lane. + * @param array $entries Captured headers of the lane, keyed by header name. + * + * @return Section Lane section carrying the heading, count, and ledger. */ private static function renderLane(string $id, string $direction, string $title, array $entries): Section { @@ -144,7 +163,12 @@ private static function renderLane(string $id, string $direction, string $title, } /** - * @param array $entries + * Renders the captured headers of one lane as a diagnostic ledger. + * + * @param array $entries Captured headers, keyed by header name. + * @param bool $response Whether the entries belong to the response lane, which labels its raw lines apart. + * + * @return string Ledger markup. */ private static function renderLedger(array $entries, bool $response): string { @@ -152,7 +176,9 @@ private static function renderLedger(array $entries, bool $response): string foreach ($entries as $name => $value) { $label = is_int($name) - ? ($response ? 'Raw response line ' : 'Raw header line ') . $name + ? ($response + ? RequestMessage::RAW_RESPONSE_LINE->value + : RequestMessage::RAW_HEADER_LINE->value) . $name : $name; $rows[] = RequestDiagnosticLedger::row( RequestDiagnosticValueRenderer::escape($label), @@ -161,6 +187,9 @@ private static function renderLedger(array $entries, bool $response): string ); } - return RequestDiagnosticLedger::render('yii-debug-header-ledger', ...$rows); + return RequestDiagnosticLedger::render( + 'yii-debug-header-ledger', + ...$rows, + ); } } diff --git a/src/Panel/Request/RequestMessage.php b/src/Panel/Request/RequestMessage.php new file mode 100644 index 0000000..f435ea1 --- /dev/null +++ b/src/Panel/Request/RequestMessage.php @@ -0,0 +1,311 @@ + $sections + * Reports whether any section carried a captured entry. + * + * @param list $sections Sections to inspect. + * + * @return bool `true` when at least one section holds an entry. */ private static function hasSectionData(array $sections): bool { @@ -61,7 +61,12 @@ private static function hasSectionData(array $sections): bool } /** - * @param list $values + * Joins a constraint list into a single line, falling back to an explicit value when it is empty. + * + * @param list $values Constraint values in declaration order. + * @param string $empty Value shown when the list is empty. + * + * @return string Comma-separated values, or the empty fallback. */ private static function listValue(array $values, string $empty): string { @@ -69,7 +74,11 @@ private static function listValue(array $values, string $empty): string } /** - * @param list $sections + * Renders each section as its own disclosure, skipping the ones the capture left empty. + * + * @param list $sections Sections to render. + * + * @return string Concatenated disclosure markup. */ private static function renderDisclosureSections(array $sections): string { @@ -82,6 +91,13 @@ private static function renderDisclosureSections(array $sections): string return $content; } + /** + * Renders the header exchange of the headers tab, or nothing when the capture recorded no header. + * + * @param RequestTab|null $tab Headers tab, or `null` when the capture declared none. + * + * @return string Header exchange markup, or `''` when there is nothing to show. + */ private static function renderHeaders(RequestTab|null $tab): string { if ($tab === null) { @@ -117,6 +133,14 @@ private static function renderHeaders(RequestTab|null $tab): string ); } + /** + * Renders one labeled item of the hero meta strip. + * + * @param string $label Item label. + * @param string $value Item value. + * + * @return Span Meta strip item. + */ private static function renderMetaItem(string $label, string $value): Span { return Span::tag() @@ -132,6 +156,15 @@ private static function renderMetaItem(string $label, string $value): Span ); } + /** + * Renders one metric of the request overview, optionally followed by a badge. + * + * @param string $label Metric label. + * @param string $value Metric value, also used as the title of the value cell. + * @param Span|null $marker Badge appended after the value, or `null` to omit it. + * + * @return Div Overview metric. + */ private static function renderMetric(string $label, string $value, Span|null $marker = null): Div { $value_ = Dd::tag()->title($value); @@ -146,6 +179,15 @@ private static function renderMetric(string $label, string $value, Span|null $ma ); } + /** + * Renders the request overview: resolved route, dispatched action, duration, and the route definition. + * + * @param RequestHero $hero Typed request header carrying the timing and client data. + * @param CurrentRouteView $current Route resolved for the request. + * @param RouteInventoryView|null $inventory Routing trace, or `null` when the adapter captured none. + * + * @return string Request overview markup. + */ private static function renderOverview( RequestHero $hero, CurrentRouteView $current, @@ -155,7 +197,7 @@ private static function renderOverview( $route = $current->getRoute() !== '' ? $current->getRoute() : ($definition?->getName() ?? ''); $action = $current->getAction() ?? $definition?->getAction() ?? ''; $method = $hero->getMethod(); - $url = $hero->getUrl() !== '' ? $hero->getUrl() : 'URL unavailable'; + $url = $hero->getUrl() !== '' ? $hero->getUrl() : RequestMessage::URL_UNAVAILABLE->value; $identity = []; @@ -185,19 +227,30 @@ private static function renderOverview( $meta = []; - foreach (['IP' => $hero->getIp(), 'Time' => $hero->getTime()] as $label => $value) { + $fields = [RequestMessage::IP->value => $hero->getIp(), RequestMessage::TIME->value => $hero->getTime()]; + + foreach ($fields as $label => $value) { if ($value !== '') { $meta[] = self::renderMetaItem($label, $value); } } if ($definition !== null) { - $meta[] = self::renderMetaItem('Pattern', $definition->getPattern()); - $meta[] = self::renderMetaItem('Methods', self::listValue($definition->getMethods(), 'Any')); - $meta[] = self::renderMetaItem('Hosts', self::listValue($definition->getHosts(), 'Any')); + $meta[] = self::renderMetaItem(RequestMessage::PATTERN->value, $definition->getPattern()); + $meta[] = self::renderMetaItem( + RequestMessage::METHODS->value, + self::listValue($definition->getMethods(), RequestMessage::ANY->value), + ); + $meta[] = self::renderMetaItem( + RequestMessage::HOSTS->value, + self::listValue($definition->getHosts(), RequestMessage::ANY->value), + ); if ($definition->getMiddlewares() !== null) { - $meta[] = self::renderMetaItem('Middleware', self::listValue($definition->getMiddlewares(), 'None')); + $meta[] = self::renderMetaItem( + RequestMessage::MIDDLEWARE->value, + self::listValue($definition->getMiddlewares(), RequestMessage::NONE->value), + ); } } @@ -235,7 +288,7 @@ private static function renderOverview( $callouts[] = self::renderResolution($current); return Section::tag() - ->addAriaAttribute('label', 'Request overview') + ->addAriaAttribute('label', RequestMessage::REQUEST_OVERVIEW->value) ->class('yii-debug-request-overview yii-debug-verb-' . Vocabulary::verb($method)) ->html( Header::tag() @@ -254,19 +307,19 @@ private static function renderOverview( ->class('yii-debug-request-overview-metrics') ->html( self::renderMetric( - 'Route', - $route !== '' ? $route : 'Unresolved', + RequestMessage::ROUTE->value, + $route !== '' ? $route : RequestMessage::UNRESOLVED->value, $definition === null ? null - : Badge::render('Matched', 'success', 'yii-debug-route-match'), + : Badge::render(RequestMessage::MATCHED->value, 'success', 'yii-debug-route-match'), ), self::renderMetric( - 'Action', - $action !== '' ? $action : 'Unavailable', + RequestMessage::ACTION->value, + $action !== '' ? $action : RequestMessage::UNAVAILABLE->value, ), self::renderMetric( - 'Duration', - $hero->getDurationMs() !== '' ? $hero->getDurationMs() : 'Unavailable', + RequestMessage::DURATION->value, + $hero->getDurationMs() !== '' ? $hero->getDurationMs() : RequestMessage::UNAVAILABLE->value, ), ), Div::tag() @@ -303,7 +356,9 @@ private static function renderResolution(CurrentRouteView $current): string $count = count($current->getTrace()); - $title = $count === 0 ? 'Routing resolution' : "Routing resolution ({$count} rules tested)"; + $title = $count === 0 + ? RequestMessage::ROUTING_RESOLUTION->value + : sprintf(RequestMessage::ROUTING_RESOLUTION_COUNT->value, $count); return Div::tag() ->class('yii-debug-route-resolution') @@ -312,7 +367,11 @@ private static function renderResolution(CurrentRouteView $current): string } /** - * @param list $sections + * Renders each section as a captioned block, skipping the ones the capture left empty. + * + * @param list $sections Sections to render. + * + * @return string Concatenated section markup. */ private static function renderSections(array $sections): string { @@ -325,6 +384,14 @@ private static function renderSections(array $sections): string return $content; } + /** + * Renders the server tab, grouping the captured variables and deriving the ones the capture missed. + * + * @param RequestTab $tab Server tab holding the captured variables. + * @param RequestView $view Typed request view, used to derive the missing variables. + * + * @return string Server tab markup. + */ private static function renderServer(RequestTab $tab, RequestView $view): string { if (count($tab->sections) !== 1 || $tab->sections[0]->id !== 'server') { @@ -337,6 +404,14 @@ private static function renderServer(RequestTab $tab, RequestView $view): string ); } + /** + * Renders the tab strip, folding the routing trace into the input tab. + * + * @param RequestView $view Typed request view carrying the captured tabs. + * @param RequestRoutingView $routing Typed routing view carrying the trace and the route inventory. + * + * @return string Tab strip markup. + */ private static function renderTabs(RequestView $view, RequestRoutingView $routing): string { $tabs = []; @@ -347,7 +422,7 @@ private static function renderTabs(RequestView $view, RequestRoutingView $routin if ($routing->current->getParameters() !== []) { $inputSections[] = new RequestSection( - caption: 'Route parameters', + caption: RequestMessage::ROUTE_PARAMETERS->value, entries: $routing->current->getParameters(), filterable: true, id: 'route-parameters', @@ -363,7 +438,7 @@ private static function renderTabs(RequestView $view, RequestRoutingView $routin } $tabs[] = [ - 'label' => 'Input', + 'label' => RequestMessage::INPUT->value, 'content' => self::hasSectionData($inputSections) ? self::renderDisclosureSections($inputSections) : EmptyState::card('No input data captured.'), @@ -372,7 +447,7 @@ private static function renderTabs(RequestView $view, RequestRoutingView $routin $headers = self::tab($view->tabs, 'headers'); $tabs[] = [ - 'label' => 'Headers', + 'label' => RequestMessage::HEADERS->value, 'content' => self::renderHeaders($headers), ]; @@ -380,7 +455,7 @@ private static function renderTabs(RequestView $view, RequestRoutingView $routin if ($session !== null) { $tabs[] = [ - 'label' => 'Session', + 'label' => RequestMessage::SESSION->value, 'content' => self::renderDisclosureSections($session->sections), ]; } @@ -389,14 +464,14 @@ private static function renderTabs(RequestView $view, RequestRoutingView $routin if ($server !== null) { $tabs[] = [ - 'label' => 'Server', + 'label' => RequestMessage::SERVER->value, 'content' => self::renderServer($server, $view), ]; } return Div::tag() ->class('yii-debug-request-tabs') - ->html(Tabs::render('request', 'Request data', $tabs)) + ->html(Tabs::render('request', RequestMessage::REQUEST_DATA->value, $tabs)) ->render(); } @@ -439,7 +514,12 @@ private static function renderTraceTable(array $trace): string } /** - * @param list $tabs + * Returns the tab carrying the requested identifier. + * + * @param list $tabs Captured tabs in display order. + * @param string $id Identifier to look up. + * + * @return RequestTab|null Matching tab, or `null` when the capture declared none. */ private static function tab(array $tabs, string $id): RequestTab|null { diff --git a/src/Panel/Request/RequestSectionRenderer.php b/src/Panel/Request/RequestSectionRenderer.php index aaa047f..af54fac 100644 --- a/src/Panel/Request/RequestSectionRenderer.php +++ b/src/Panel/Request/RequestSectionRenderer.php @@ -26,7 +26,7 @@ public static function renderDisclosureSection(RequestSection $section): string if ($section->entries === []) { $content = P::tag() ->class('yii-debug-table-empty') - ->content('No data') + ->content(RequestMessage::NO_DATA->value) ->render(); } else { $filter = self::renderFilter($section); @@ -72,7 +72,13 @@ public static function renderHero(RequestHero $hero): string $meta = []; - foreach (['IP' => $hero->getIp(), 'Time' => $hero->getTime(), 'Duration' => $hero->getDurationMs()] as $label => $value) { + $fields = [ + RequestMessage::IP->value => $hero->getIp(), + RequestMessage::TIME->value => $hero->getTime(), + RequestMessage::DURATION->value => $hero->getDurationMs(), + ]; + + foreach ($fields as $label => $value) { if ($value !== '') { $meta[] = Span::tag() ->class('yii-debug-request-hero-meta-item') @@ -155,11 +161,18 @@ public static function renderTabs(array $tabs): string return Tabs::render( 'request', - 'Request data', + RequestMessage::REQUEST_DATA->value, $items, ); } + /** + * Renders the filter input of a section, or nothing when the section declares itself unfilterable. + * + * @param RequestSection $section Section to filter. + * + * @return InputSearch|null Filter input, or `null` when the section is not filterable. + */ private static function renderFilter(RequestSection $section): InputSearch|null { if ($section->filterable === false) { @@ -227,8 +240,7 @@ private static function renderSectionTable(RequestSection $section): string $rows[] = self::renderRow($name, $value); } - $wrap = Div::tag() - ->class('yii-debug-table-wrap'); + $wrap = Div::tag()->class('yii-debug-table-wrap'); if ($section->filterable) { $wrap = $wrap->addDataAttribute('yii-debug-filter-target', true); @@ -236,7 +248,11 @@ private static function renderSectionTable(RequestSection $section): string return $wrap ->html( - Table::build(['Name', 'Value'], $rows, 'yii-debug-table yii-debug-table-mono') + Table::build( + [RequestMessage::NAME->value, RequestMessage::VALUE->value], + $rows, + 'yii-debug-table yii-debug-table-mono', + ) ->style(['table-layout' => 'fixed']), ) ->render(); diff --git a/src/Panel/Request/RequestServerRenderer.php b/src/Panel/Request/RequestServerRenderer.php index ea3d140..6e757e1 100644 --- a/src/Panel/Request/RequestServerRenderer.php +++ b/src/Panel/Request/RequestServerRenderer.php @@ -46,9 +46,12 @@ public static function renderForRequest(array $entries, RequestView $view): stri } /** - * @param array $entries + * Derives the variables the capture did not record, reconstructing them from the captured URL and headers. + * + * @param array $entries Captured server variables, keyed by variable name. + * @param RequestView $view Typed request view carrying the hero and its headers. * - * @return array + * @return array Captured variables completed with the derived ones. */ private static function additionalEntries(array $entries, RequestView $view): array { @@ -110,6 +113,13 @@ private static function additionalEntries(array $entries, RequestView $view): ar return $entries; } + /** + * Renders one variable group as a disclosure carrying its own filter and ledger. + * + * @param ServerVariableGroup $group Group to render. + * + * @return string Group markup. + */ private static function renderGroup(ServerVariableGroup $group): string { $description = $group->id === 'raw' @@ -147,7 +157,7 @@ private static function renderGroup(ServerVariableGroup $group): string ->class('yii-debug-mini-toolbar') ->html( InputSearch::tag() - ->addAriaAttribute('label', 'Filter ' . $group->label) + ->addAriaAttribute('label', RequestMessage::FILTER_PREFIX->value . $group->label) ->addDataAttribute('yii-debug-filter', true) ->class('yii-debug-filter-input') ->placeholder('Filter variables…'), @@ -170,7 +180,11 @@ private static function renderGroup(ServerVariableGroup $group): string } /** - * @param array $entries + * Renders a group of server variables as a diagnostic ledger. + * + * @param array $entries Server variables, keyed by variable name. + * + * @return string Ledger markup. */ private static function renderLedger(array $entries): string { @@ -184,12 +198,19 @@ private static function renderLedger(array $entries): string ); } - return RequestDiagnosticLedger::render('yii-debug-server-ledger', ...$rows); + return RequestDiagnosticLedger::render( + 'yii-debug-server-ledger', + ...$rows, + ); } /** - * @param array $entries - * @param array $additional + * Renders the grouped variables followed by the raw table holding every captured one. + * + * @param array $entries Captured server variables, keyed by variable name. + * @param array $additional Captured variables completed with the derived ones. + * + * @return string Server section markup. */ private static function renderView(array $entries, array $additional): string { @@ -199,7 +220,9 @@ private static function renderView(array $entries, array $additional): string $groups .= self::renderGroup( new ServerVariableGroup( $group->id, - $group->id === 'header-mirrors' ? 'Additional header variables' : $group->label, + $group->id === 'header-mirrors' + ? RequestMessage::ADDITIONAL_HEADER_VARIABLES->value + : $group->label, $group->entries, ), ); @@ -224,7 +247,7 @@ private static function renderView(array $entries, array $additional): string ->html( H2::tag() ->id('yii-debug-server-environment-title') - ->content('Server details'), + ->content(RequestMessage::SERVER_DETAILS->value), Span::tag() ->class('yii-debug-diagnostic-total') ->content(count($additional) . ' additional / ' . count($entries) . ' captured'), @@ -235,7 +258,7 @@ private static function renderView(array $entries, array $additional): string ->html($groups), $entries === [] ? '' : self::renderGroup(new ServerVariableGroup( 'raw', - 'Raw server variables', + RequestMessage::RAW_SERVER_VARIABLES->value, $entries, true, )), diff --git a/src/Panel/Request/ServerVariableGrouper.php b/src/Panel/Request/ServerVariableGrouper.php index 684d04e..c2c068e 100644 --- a/src/Panel/Request/ServerVariableGrouper.php +++ b/src/Panel/Request/ServerVariableGrouper.php @@ -14,16 +14,27 @@ */ final class ServerVariableGrouper { + /** + * @var array Heading and collapsed state of every group, in display order. + */ private const array DEFINITIONS = [ - 'request-context' => ['Request context', false], - 'network-transport' => ['Network & transport', false], - 'runtime-paths' => ['Runtime & paths', false], - 'header-mirrors' => ['Header mirrors', true], - 'environment-other' => ['Environment & other', false], + 'request-context' => [RequestMessage::REQUEST_CONTEXT->value, false], + 'network-transport' => [RequestMessage::NETWORK_TRANSPORT->value, false], + 'runtime-paths' => [RequestMessage::RUNTIME_PATHS->value, false], + 'header-mirrors' => [RequestMessage::HEADER_MIRRORS->value, true], + 'environment-other' => [RequestMessage::ENVIRONMENT_OTHER->value, false], ]; - - private const array HEADER_MIRRORS = ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5']; - + /** + * @var list Variables mirroring a content header, grouped apart from the request context. + */ + private const array HEADER_MIRRORS = [ + 'CONTENT_TYPE', + 'CONTENT_LENGTH', + 'CONTENT_MD5', + ]; + /** + * @var list Variables describing the connection, beyond the `REMOTE_` and `SSL_` prefixes. + */ private const array NETWORK_KEYS = [ 'SERVER_ADDR', 'SERVER_NAME', @@ -32,9 +43,17 @@ final class ServerVariableGrouper 'HTTPS', 'GATEWAY_INTERFACE', ]; - - private const array REQUEST_KEYS = ['QUERY_STRING', 'PATH_INFO', 'ORIG_PATH_INFO']; - + /** + * @var list Variables describing the request, beyond the `REQUEST_` prefix. + */ + private const array REQUEST_KEYS = [ + 'QUERY_STRING', + 'PATH_INFO', + 'ORIG_PATH_INFO', + ]; + /** + * @var list Variables describing the runtime and its paths, beyond the handled prefixes. + */ private const array RUNTIME_KEYS = [ 'SERVER_SOFTWARE', 'DOCUMENT_ROOT', @@ -43,9 +62,11 @@ final class ServerVariableGrouper ]; /** - * @param array $entries + * Partitions the captured variables into the declared groups, dropping the groups that stay empty. * - * @return list + * @param array $entries Captured server variables, keyed by variable name. + * + * @return list Non-empty groups in display order. */ public static function group(array $entries): array { @@ -72,6 +93,13 @@ public static function group(array $entries): array return $groups; } + /** + * Resolves the group a captured variable belongs to. + * + * @param int|string $key Captured variable name; a non-string key falls back to the catch-all group. + * + * @return string Identifier of the group claiming the variable. + */ private static function classify(int|string $key): string { if (!is_string($key)) { diff --git a/src/Panel/Router/RouterMessage.php b/src/Panel/Router/RouterMessage.php new file mode 100644 index 0000000..7909191 --- /dev/null +++ b/src/Panel/Router/RouterMessage.php @@ -0,0 +1,181 @@ +urlManager(true, false, '') - * ->rules($ruleRows) - * ->actionRoutes($actionRows) - * ->present($snapshot->jsonSerialize()); - * ``` - * * @phpstan-import-type BadgeInline from PanelView */ final class RouterPanel extends Panel @@ -31,43 +22,32 @@ final class RouterPanel extends Panel /** * @var string Icon key shared with the built-in Router navigation entry. */ - protected const string ICON = 'router'; - + protected const string ICON = RouterMessage::ID->value; /** * @var string Stable identifier associating the panel with the captured routing payload. */ - protected const string ID = 'router'; - + protected const string ID = RouterMessage::ID->value; /** * @var string Panel title used in the debugger navigation. */ - protected const string TITLE = 'Router'; - - /** - * @var string Placeholder shown wherever the capture left a field empty. - */ - private const string PLACEHOLDER = '—'; + protected const string TITLE = RouterMessage::TITLE->value; /** * @var list Discovered action routes in display order. */ private array $actionRows = []; - /** * @var bool Whether the URL manager generates and parses pretty URLs. */ private bool $prettyUrl = false; - /** * @var list Configured URL rules in display order. */ private array $ruleRows = []; - /** * @var bool Whether the URL manager only accepts requests matching a configured rule. */ private bool $strictParsing = false; - /** * @var string Global URL suffix, or `''` when the URL manager declares none. */ @@ -97,7 +77,10 @@ public function actionRoutes(array $rows): self */ public function present(array $data): PanelView { - $snapshot = RouterSnapshot::fromArray($data, '$.router'); + $snapshot = RouterSnapshot::fromArray( + $data, + '$.router', + ); $entries = $snapshot->entries(); @@ -105,17 +88,28 @@ public function present(array $data): PanelView $route = $snapshot->route; + $label = $route === '' ? RouterMessage::PLACEHOLDER->value : $route; + $view = PanelView::create() - ->summary('', $route === '' ? self::PLACEHOLDER : $route) - ->summary($tested === 1 ? ' rule tested' : ' rules tested', $tested) - ->toolbar('Route', $route === '' ? self::PLACEHOLDER : $route) + ->summary('', $label) + ->summary( + $tested === 1 + ? RouterMessage::RULE_TESTED_SUFFIX->value + : RouterMessage::RULES_TESTED_SUFFIX->value, + $tested, + ) + ->toolbar(RouterMessage::ROUTE->value, $label) ->overview( [ - 'Route' => $route === '' ? self::PLACEHOLDER : PanelView::code($route), - 'Action' => self::action($snapshot), - 'Pretty URL' => self::flag($this->prettyUrl), - 'Strict parsing' => self::flag($this->strictParsing), - 'Global suffix' => $this->suffix === '' ? self::PLACEHOLDER : PanelView::code($this->suffix), + RouterMessage::ROUTE->value => $route === '' + ? RouterMessage::PLACEHOLDER->value + : PanelView::code($route), + RouterMessage::ACTION->value => self::action($snapshot), + RouterMessage::PRETTY_URL->value => self::flag($this->prettyUrl), + RouterMessage::STRICT_PARSING->value => self::flag($this->strictParsing), + RouterMessage::GLOBAL_SUFFIX->value => $this->suffix === '' + ? RouterMessage::PLACEHOLDER->value + : PanelView::code($this->suffix), ], true, ); @@ -127,7 +121,7 @@ public function present(array $data): PanelView $view = $view->heading(self::testedHeading($tested, $snapshot->hasMatch()), true); if ($entries === []) { - $view = $view->paragraph('The router captured no rule trace for this request.'); + $view = $view->paragraph(RouterMessage::NO_TRACE->value); } else { $rows = []; @@ -135,15 +129,20 @@ public function present(array $data): PanelView $rows[] = [ $index + 1, $entry->rule, - $entry->parent === '' ? self::PLACEHOLDER : $entry->parent, + $entry->parent === '' ? RouterMessage::PLACEHOLDER->value : $entry->parent, $entry->match - ? PanelView::badge('match', Tone::SUCCESS) - : PanelView::badge('no match', Tone::MUTED), + ? PanelView::badge(RouterMessage::MATCH_RESULT->value, Tone::SUCCESS) + : PanelView::badge(RouterMessage::NO_MATCH_RESULT->value, Tone::MUTED), ]; } $view = $view->table( - ['#', 'Rule', 'Parent', 'Result'], + [ + RouterMessage::NUMBER->value, + RouterMessage::RULE->value, + RouterMessage::PARENT_RULE->value, + RouterMessage::RESULT->value, + ], $rows, true, [ @@ -155,12 +154,20 @@ public function present(array $data): PanelView ); } - $view = $view->heading(sprintf('URL rules (%d)', count($this->ruleRows)), true); + $view = $view->heading(sprintf(RouterMessage::URL_RULES->value, count($this->ruleRows)), true); $view = $this->ruleRows === [] - ? $view->paragraph('The URL manager declares no rules.') + ? $view->paragraph(RouterMessage::URL_RULES_EMPTY->value) : $view->table( - ['#', 'Name', 'Route', 'Verb', 'Suffix', 'Mode', 'Type'], + [ + RouterMessage::NUMBER->value, + RouterMessage::NAME->value, + RouterMessage::ROUTE->value, + RouterMessage::VERB->value, + RouterMessage::SUFFIX->value, + RouterMessage::MODE->value, + RouterMessage::TYPE->value, + ], self::ruleRows($this->ruleRows), true, [ @@ -172,12 +179,18 @@ public function present(array $data): PanelView ], ); - $view = $view->heading(sprintf('Action routes (%d)', count($this->actionRows)), true); + $view = $view->heading(sprintf(RouterMessage::ACTION_ROUTES->value, count($this->actionRows)), true); return $this->actionRows === [] - ? $view->paragraph('No actions are configured.') + ? $view->paragraph(RouterMessage::NO_ACTIONS->value) : $view->table( - ['#', 'Action', 'Route', 'First matching rule', 'Rules tested'], + [ + RouterMessage::NUMBER->value, + RouterMessage::ACTION->value, + RouterMessage::ROUTE->value, + RouterMessage::FIRST_RULE->value, + RouterMessage::RULES_TESTED->value, + ], self::actions($this->actionRows), true, [ @@ -235,7 +248,7 @@ private static function action(RouterSnapshot $snapshot): string { $action = $snapshot->action; - return $action === null || $action === '' ? self::PLACEHOLDER : $action; + return $action === null || $action === '' ? RouterMessage::PLACEHOLDER->value : $action; } /** @@ -253,8 +266,8 @@ private static function actions(array $rows): array $result[] = [ $index + 1, $row->action, - $row->route === '' ? self::PLACEHOLDER : $row->route, - $row->rule === '' ? self::PLACEHOLDER : $row->rule, + $row->route === '' ? RouterMessage::PLACEHOLDER->value : $row->route, + $row->rule === '' ? RouterMessage::PLACEHOLDER->value : $row->rule, $row->count, ]; } @@ -272,8 +285,8 @@ private static function actions(array $rows): array private static function flag(bool $enabled): array { return $enabled - ? PanelView::badge('enabled', Tone::SUCCESS) - : PanelView::badge('disabled', Tone::MUTED); + ? PanelView::badge(RouterMessage::ENABLED->value, Tone::SUCCESS) + : PanelView::badge(RouterMessage::DISABLED->value, Tone::MUTED); } /** @@ -291,11 +304,11 @@ private static function ruleRows(array $rows): array $result[] = [ $index + 1, $row->name, - $row->route === '' ? self::PLACEHOLDER : $row->route, - $row->verb === '' ? self::PLACEHOLDER : $row->verb, - $row->suffix === '' ? self::PLACEHOLDER : $row->suffix, - $row->mode === '' ? self::PLACEHOLDER : $row->mode, - $row->type === '' ? self::PLACEHOLDER : $row->type, + $row->route === '' ? RouterMessage::PLACEHOLDER->value : $row->route, + $row->verb === '' ? RouterMessage::PLACEHOLDER->value : $row->verb, + $row->suffix === '' ? RouterMessage::PLACEHOLDER->value : $row->suffix, + $row->mode === '' ? RouterMessage::PLACEHOLDER->value : $row->mode, + $row->type === '' ? RouterMessage::PLACEHOLDER->value : $row->type, ]; } @@ -313,14 +326,14 @@ private static function ruleRows(array $rows): array private static function testedHeading(int $tested, bool $matched): string { if ($tested === 0) { - return 'Rules tested'; + return RouterMessage::RULES_TESTED->value; } return sprintf( - 'Tested %d %s%s', + RouterMessage::TESTED_HEADING->value, $tested, - $tested === 1 ? 'rule' : 'rules', - $matched ? ' before match' : '', + $tested === 1 ? RouterMessage::RULE_NOUN->value : RouterMessage::RULES_NOUN->value, + $matched ? RouterMessage::TESTED_BEFORE_MATCH->value : '', ); } } diff --git a/src/Panel/User/UserMessage.php b/src/Panel/User/UserMessage.php new file mode 100644 index 0000000..c101fb1 --- /dev/null +++ b/src/Panel/User/UserMessage.php @@ -0,0 +1,136 @@ +user->identity'; + + /** + * Closing sentence of the empty-state call to action. + */ + case EMPTY_RESOLVES = ' resolves.'; + + /** + * Call to action of the empty state, preceding the identity accessor. + */ + case EMPTY_SIGN_IN = 'Sign in and reload the page; the identity appears here as soon as '; + + /** + * Stable identifier associating the panel with the captured payload, also used as its icon key. + */ + case ID = 'user'; + + /** + * Header of the RBAC item name column. + */ + case NAME = 'Name'; + + /** + * Header of the position column, which sorts by capture order. + */ + case NUMBER = '#'; + + /** + * Section label of the granted permissions. + */ + case PERMISSIONS = 'Permissions'; + + /** + * Placeholder shown wherever the capture left a field empty. + */ + case PLACEHOLDER = '—'; + + /** + * `sprintf()` template of the note shown when the auth manager granted no item, naming the lowercased section. + */ + case RBAC_EMPTY = 'The auth manager granted no %s to this identity.'; + + /** + * `sprintf()` template of an RBAC section heading, naming the section and its item count. + */ + case RBAC_HEADING = '%s (%d)'; + + /** + * Section label of the granted roles. + */ + case ROLES = 'Roles'; + + /** + * Header of the RBAC rule column. + */ + case RULE = 'Rule'; + + /** + * Overview field label of the account status. + */ + case STATUS = 'Status'; + + /** + * Badge label of an account whose status the capture did not resolve. + */ + case STATUS_UNKNOWN = 'Unknown'; + + /** + * `sprintf()` template joining the absolute and relative forms of a captured timestamp. + */ + case TIMESTAMP_JOIN = '%s · %s'; + + /** + * Panel title used in the debugger navigation, also the identity overview label and toolbar metric. + */ + case TITLE = 'User'; + + /** + * Header of the RBAC update-time column. + */ + case UPDATED = 'Updated'; + + /** + * Overview field label of the identity primary key. + */ + case USER_ID = 'User ID'; +} diff --git a/src/Panel/User/UserPanel.php b/src/Panel/User/UserPanel.php index 3cdeaf8..4252e9b 100644 --- a/src/Panel/User/UserPanel.php +++ b/src/Panel/User/UserPanel.php @@ -23,28 +23,15 @@ final class UserPanel extends Panel /** * @var string Icon key shared with the built-in User navigation entry. */ - protected const string ICON = 'user'; - + protected const string ICON = UserMessage::ID->value; /** * @var string Stable identifier associating the panel with the captured identity payload. */ - protected const string ID = 'user'; - + protected const string ID = UserMessage::ID->value; /** * @var string Panel title used in the debugger navigation. */ - protected const string TITLE = 'User'; - - /** - * @var string Absolute timestamp format of the RBAC tables. - */ - private const string DATE_FORMAT = 'M j, Y · H:i:s'; - - /** - * @var string Placeholder shown wherever the capture left a field empty. - */ - private const string PLACEHOLDER = '—'; - + protected const string TITLE = UserMessage::TITLE->value; /** * @var array Tone applied to each status variant the normalizer resolves. */ @@ -71,20 +58,20 @@ public function present(array $data): PanelView return PanelView::create() ->active(false) ->emptyState( - 'No authenticated user', - 'This request ran as a guest, so the debugger captured no identity to inspect.', + UserMessage::EMPTY_HEADLINE->value, + UserMessage::EMPTY_EXPLANATION->value, [ - 'Sign in and reload the page; the identity appears here as soon as ', - PanelView::code('Yii::$app->user->identity'), - ' resolves.', + UserMessage::EMPTY_SIGN_IN->value, + PanelView::code(UserMessage::EMPTY_IDENTITY->value), + UserMessage::EMPTY_RESOLVES->value, ], ); } $view = self::identity(self::strings($identity), $payload['attributes'] ?? null); - $view = self::rbac($view, 'Roles', $payload['roles'] ?? null); + $view = self::rbac($view, UserMessage::ROLES, $payload['roles'] ?? null); - return self::rbac($view, 'Permissions', $payload['permissions'] ?? null); + return self::rbac($view, UserMessage::PERMISSIONS, $payload['permissions'] ?? null); } /** @@ -97,7 +84,7 @@ public function present(array $data): PanelView private static function attribute(UserAttribute $attribute): mixed { return match ($attribute->kind) { - UserAttribute::KIND_EMPTY => self::PLACEHOLDER, + UserAttribute::KIND_EMPTY => UserMessage::PLACEHOLDER->value, UserAttribute::KIND_SECURITY => PanelView::preview($attribute->displayValue), UserAttribute::KIND_TIMESTAMP => self::timestampLabel($attribute), default => $attribute->displayValue, @@ -125,13 +112,17 @@ private static function identity(array $identity, mixed $attributes): PanelView $view = PanelView::create() ->summary('', $hero->username) - ->toolbar('User', $hero->username) + ->toolbar(UserMessage::TITLE->value, $hero->username) ->overview( [ - 'User' => $hero->username, - 'Email' => $hero->email === '' ? self::PLACEHOLDER : $hero->email, - 'User ID' => $hero->idValue === '' ? self::PLACEHOLDER : $hero->idValue, - 'Status' => self::status($hero), + UserMessage::TITLE->value => $hero->username, + UserMessage::EMAIL->value => $hero->email === '' + ? UserMessage::PLACEHOLDER->value + : $hero->email, + UserMessage::USER_ID->value => $hero->idValue === '' + ? UserMessage::PLACEHOLDER->value + : $hero->idValue, + UserMessage::STATUS->value => self::status($hero), ], true, ); @@ -182,12 +173,12 @@ private static function labels(array $attributes): array * Appends one RBAC section, explaining the absence when the auth manager exposed no item. * * @param PanelView $view View to extend. - * @param string $label Section label, either roles or permissions. + * @param UserMessage $label Section label, either roles or permissions. * @param mixed $rows Captured RBAC rows, or `null` when the auth manager exposed none. * * @return PanelView View completed with the RBAC section. */ - private static function rbac(PanelView $view, string $label, mixed $rows): PanelView + private static function rbac(PanelView $view, UserMessage $label, mixed $rows): PanelView { $items = []; @@ -197,10 +188,15 @@ private static function rbac(PanelView $view, string $label, mixed $rows): Panel } } - $view = $view->heading(sprintf('%s (%d)', $label, count($items)), true); + $view = $view->heading( + sprintf(UserMessage::RBAC_HEADING->value, $label->value, count($items)), + true, + ); if ($items === []) { - return $view->paragraph('The auth manager granted no ' . strtolower($label) . ' to this identity.'); + return $view->paragraph( + sprintf(UserMessage::RBAC_EMPTY->value, strtolower($label->value)), + ); } $table = []; @@ -208,17 +204,25 @@ private static function rbac(PanelView $view, string $label, mixed $rows): Panel foreach ($items as $index => $item) { $table[] = [ $index + 1, - $item->name === '' ? self::PLACEHOLDER : $item->name, - $item->description === '' ? self::PLACEHOLDER : $item->description, - $item->ruleName === '' ? self::PLACEHOLDER : $item->ruleName, - $item->data === '' ? self::PLACEHOLDER : $item->data, + $item->name === '' ? UserMessage::PLACEHOLDER->value : $item->name, + $item->description === '' ? UserMessage::PLACEHOLDER->value : $item->description, + $item->ruleName === '' ? UserMessage::PLACEHOLDER->value : $item->ruleName, + $item->data === '' ? UserMessage::PLACEHOLDER->value : $item->data, self::timestamp($item->createdAt), self::timestamp($item->updatedAt), ]; } return $view->table( - ['#', 'Name', 'Description', 'Rule', 'Data', 'Created', 'Updated'], + [ + UserMessage::NUMBER->value, + UserMessage::NAME->value, + UserMessage::DESCRIPTION->value, + UserMessage::RULE->value, + UserMessage::DATA->value, + UserMessage::CREATED->value, + UserMessage::UPDATED->value, + ], $table, true, [ @@ -243,7 +247,7 @@ private static function rbac(PanelView $view, string $label, mixed $rows): Panel */ private static function status(UserIdentityHero $hero): array { - $label = $hero->statusLabel === '' ? 'Unknown' : $hero->statusLabel; + $label = $hero->statusLabel === '' ? UserMessage::STATUS_UNKNOWN->value : $hero->statusLabel; return PanelView::badge( $label, @@ -280,7 +284,9 @@ private static function strings(array $identity): array */ private static function timestamp(int|null $timestamp): string { - return $timestamp === null ? self::PLACEHOLDER : date(self::DATE_FORMAT, $timestamp); + return $timestamp === null + ? UserMessage::PLACEHOLDER->value + : date(UserMessage::DATE_FORMAT->value, $timestamp); } /** @@ -298,6 +304,8 @@ private static function timestampLabel(UserAttribute $attribute): string $absolute = $attribute->timestampAbs; $relative = $attribute->timestampRel; - return $relative === $absolute ? $absolute : "{$absolute} · {$relative}"; + return $relative === $absolute + ? $absolute + : sprintf(UserMessage::TIMESTAMP_JOIN->value, $absolute, $relative); } } diff --git a/src/Toolbar/DebugHeader.php b/src/Toolbar/DebugHeader.php new file mode 100644 index 0000000..3071065 --- /dev/null +++ b/src/Toolbar/DebugHeader.php @@ -0,0 +1,26 @@ +class('yii-debug-snapshot-tag') ->addDataAttribute('snapshot-field', 'ajax') - ->content('AJAX'); + ->content(ViewMessage::AJAX->value); if ($snapshot->isAjax === false) { $ajax = $ajax->addAttribute('hidden', true); @@ -188,8 +189,8 @@ private static function renderNavRow(SidebarSnapshot $snapshot): Div 'newest', $snapshot->isNewest, $snapshot->newestUrl, - 'Newest request', - 'Newest captured request', + ViewMessage::NEWEST_REQUEST->value, + ViewMessage::NEWEST_CAPTURED_REQUEST->value, $iconNewest, ), self::renderNavButton( @@ -286,7 +287,9 @@ private static function renderSnapshotSection(SidebarSnapshot $snapshot): Sectio } return $section->html( - Header::tag()->class('yii-debug-side-section-title')->content($snapshot->title), + Header::tag() + ->class('yii-debug-side-section-title') + ->content($snapshot->title), self::renderHistoryCard($snapshot), ); } diff --git a/src/View/ViewMessage.php b/src/View/ViewMessage.php new file mode 100644 index 0000000..9da9fcc --- /dev/null +++ b/src/View/ViewMessage.php @@ -0,0 +1,136 @@ + true]), - MailMessage::fromCapture(['isSuccessful' => false]), - MailMessage::fromCapture(['no-flag' => 'missing counts as failed']), + MailEntry::fromCapture(['isSuccessful' => true]), + MailEntry::fromCapture(['isSuccessful' => false]), + MailEntry::fromCapture(['no-flag' => 'missing counts as failed']), ], ); @@ -39,30 +39,28 @@ public function testFailedCountReturnsZeroForEmptyList(): void { self::assertSame( 0, - MailMessage::failedCount([]), + MailEntry::failedCount([]), 'Empty list must yield zero.', ); } public function testFromCaptureCoercesScalarHeaderFieldsToStrings(): void { - $message = MailMessage::fromCapture( - ['from' => 42, 'subject' => true, 'charset' => 1.5], - ); + $message = MailEntry::fromCapture(['from' => 42, 'subject' => true, 'charset' => 1.5]); self::assertSame( '42', - $message->from, + $message->getFrom(), 'Int sender must coerce to string.', ); self::assertSame( '1', - $message->subject, + $message->getSubject(), 'Bool subject must coerce to string.', ); self::assertSame( '1.5', - $message->charset, + $message->getCharset(), 'Float charset must coerce to string.', ); } @@ -76,13 +74,11 @@ public function __toString(): string } }; - $message = MailMessage::fromCapture( - ['subject' => $stringable], - ); + $message = MailEntry::fromCapture(['subject' => $stringable]); self::assertSame( 'rendered', - $message->subject, + $message->getSubject(), "Stringable subject must coerce via '__toString()'.", ); } @@ -91,7 +87,7 @@ public function testFromCaptureCollapsesNonStringFileToEmpty(): void { self::assertSame( '', - MailMessage::fromCapture(['file' => 42])->file, + MailEntry::fromCapture(['file' => 42])->getFile(), "Non-string `file` must collapse to ''.", ); } @@ -99,53 +95,46 @@ public function testFromCaptureCollapsesNonStringFileToEmpty(): void public function testFromCaptureCollapsesUnparseableTimeToNull(): void { self::assertNull( - MailMessage::fromCapture(['time' => 'not a date'])->time, + MailEntry::fromCapture(['time' => 'not a date'])->getTime(), "Garbage string must collapse to 'null'.", ); self::assertNull( - MailMessage::fromCapture(['time' => ''])->time, + MailEntry::fromCapture(['time' => ''])->getTime(), "Empty string must collapse to 'null'.", ); self::assertNull( - MailMessage::fromCapture(['time' => null])->time, + MailEntry::fromCapture(['time' => null])->getTime(), "'null' must collapse to 'null'.", ); self::assertNull( - MailMessage::fromCapture(['time' => ['nested']])->time, + MailEntry::fromCapture(['time' => ['nested']])->getTime(), "Array must collapse to 'null'.", ); } public function testFromCaptureDropsEmptySegmentsBetweenCommas(): void { - $message = MailMessage::fromCapture( - ['to' => 'a@example.com,, ,b@example.com,'], - ); + $message = MailEntry::fromCapture(['to' => 'a@example.com,, ,b@example.com,']); self::assertSame( ['a@example.com', 'b@example.com'], - $message->to, + $message->getTo(), 'Empty segments must be dropped.', ); } public function testFromCaptureFallsBackToEmptyWhenStringFieldsAreNonScalar(): void { - $message = MailMessage::fromCapture( - [ - 'from' => ['nested'], - 'subject' => null, - ], - ); + $message = MailEntry::fromCapture(['from' => ['nested'], 'subject' => null]); self::assertSame( '', - $message->from, + $message->getFrom(), 'Array `from` must collapse to `\'\'`.', ); self::assertSame( '', - $message->subject, + $message->getSubject(), 'Null `subject` must collapse to `\'\'`.', ); } @@ -154,7 +143,7 @@ public function testFromCaptureKeepsIntTimeAsIs(): void { self::assertSame( 1_700_000_000, - MailMessage::fromCapture(['time' => 1_700_000_000])->time, + MailEntry::fromCapture(['time' => 1_700_000_000])->getTime(), 'Int time must round-trip unchanged.', ); } @@ -162,23 +151,23 @@ public function testFromCaptureKeepsIntTimeAsIs(): void public function testFromCaptureMapsTruthyIsSuccessfulOnlyWhenStrictlyTrue(): void { self::assertTrue( - MailMessage::fromCapture(['isSuccessful' => true])->isSuccessful, + MailEntry::fromCapture(['isSuccessful' => true])->isSuccessful(), "'true' must round-trip.", ); self::assertFalse( - MailMessage::fromCapture(['isSuccessful' => 1])->isSuccessful, + MailEntry::fromCapture(['isSuccessful' => 1])->isSuccessful(), "'1' must not be accepted (strict comparison)." ); self::assertFalse( - MailMessage::fromCapture(['isSuccessful' => 'true'])->isSuccessful, + MailEntry::fromCapture(['isSuccessful' => 'true'])->isSuccessful(), "'true' must not be accepted." ); self::assertFalse( - MailMessage::fromCapture(['isSuccessful' => false])->isSuccessful, + MailEntry::fromCapture(['isSuccessful' => false])->isSuccessful(), "'false' must yield 'false'." ); self::assertFalse( - MailMessage::fromCapture([])->isSuccessful, + MailEntry::fromCapture([])->isSuccessful(), "Missing flag must default to 'false'." ); } @@ -187,97 +176,93 @@ public function testFromCaptureParsesDateTimeInterfaceAsUnixTimestamp(): void { $datetime = new DateTimeImmutable('2024-06-15T12:34:56+00:00'); - $message = MailMessage::fromCapture( - ['time' => $datetime], - ); + $message = MailEntry::fromCapture(['time' => $datetime]); self::assertSame( $datetime->getTimestamp(), - $message->time, + $message->getTime(), 'DateTimeInterface must yield its Unix timestamp.', ); } public function testFromCaptureParsesStringTimeViaStrtotime(): void { - $message = MailMessage::fromCapture( - ['time' => '2024-06-15T12:34:56+00:00'], - ); + $message = MailEntry::fromCapture(['time' => '2024-06-15T12:34:56+00:00']); self::assertSame( strtotime('2024-06-15T12:34:56+00:00'), - $message->time, + $message->getTime(), 'Parseable string must coerce via `strtotime`.', ); } public function testFromCaptureReturnsAllEmptyDefaultsForAnEmptyPayload(): void { - $message = MailMessage::fromCapture([]); + $message = MailEntry::fromCapture([]); self::assertSame( '', - $message->from, + $message->getFrom(), "Non-array input must yield empty 'from'.", ); self::assertSame( [], - $message->to, + $message->getTo(), "Non-array input must yield empty 'to'.", ); self::assertSame( [], - $message->cc, + $message->getCc(), "Non-array input must yield empty 'cc'.", ); self::assertSame( [], - $message->bcc, + $message->getBcc(), "Non-array input must yield empty 'bcc'.", ); self::assertSame( [], - $message->replyTo, + $message->getReplyTo(), "Non-array input must yield empty 'replyTo'.", ); self::assertSame( '', - $message->subject, + $message->getSubject(), "Non-array input must yield empty 'subject'.", ); self::assertSame( '', - $message->body, + $message->getBody(), "Non-array input must yield empty 'body'.", ); self::assertSame( '', - $message->headers, + $message->getHeaders(), "Non-array input must yield empty 'headers'.", ); self::assertSame( '', - $message->charset, + $message->getCharset(), "Non-array input must yield empty 'charset'.", ); self::assertSame( '', - $message->file, + $message->getFile(), "Non-array input must yield empty 'file'.", ); self::assertFalse( - $message->isSuccessful, + $message->isSuccessful(), "Non-array input must yield 'isSuccessful = false'.", ); self::assertNull( - $message->time, + $message->getTime(), "Non-array input must yield 'null' 'time'.", ); } public function testFromCaptureRoundTripsTypedFields(): void { - $message = MailMessage::fromCapture( + $message = MailEntry::fromCapture( [ 'from' => 'sender@example.com', 'subject' => 'Hello', @@ -291,43 +276,43 @@ public function testFromCaptureRoundTripsTypedFields(): void self::assertSame( 'sender@example.com', - $message->from, + $message->getFrom(), 'From must round-trip.', ); self::assertSame( 'Hello', - $message->subject, + $message->getSubject(), 'Subject must round-trip.', ); self::assertSame( 'Body content.', - $message->body, + $message->getBody(), 'Body must round-trip.', ); self::assertSame( 'X-Foo: bar', - $message->headers, + $message->getHeaders(), 'Headers must round-trip.', ); self::assertSame( 'UTF-8', - $message->charset, + $message->getCharset(), 'Charset must round-trip.', ); self::assertSame( '/tmp/mail.eml', - $message->file, + $message->getFile(), 'File path must round-trip.', ); self::assertTrue( - $message->isSuccessful, + $message->isSuccessful(), '`isSuccessful = true` must round-trip.', ); } public function testFromCaptureSplitsCommaSeparatedRecipients(): void { - $message = MailMessage::fromCapture( + $message = MailEntry::fromCapture( [ 'to' => 'a@example.com, b@example.com,c@example.com', 'cc' => 'cc@example.com', @@ -338,22 +323,22 @@ public function testFromCaptureSplitsCommaSeparatedRecipients(): void self::assertSame( ['a@example.com', 'b@example.com', 'c@example.com'], - $message->to, + $message->getTo(), 'TO must split on commas and trim.', ); self::assertSame( ['cc@example.com'], - $message->cc, + $message->getCc(), 'Single CC must yield a one-element list.', ); self::assertSame( [], - $message->bcc, + $message->getBcc(), 'Empty BCC string must yield `[]`.', ); self::assertSame( ['reply1@example.com', 'reply2@example.com'], - $message->replyTo, + $message->getReplyTo(), 'Reply-to must split on commas.', ); } diff --git a/tests/Provider/EventMessageProvider.php b/tests/Provider/EventMessageProvider.php index 4c5964c..9e86089 100644 --- a/tests/Provider/EventMessageProvider.php +++ b/tests/Provider/EventMessageProvider.php @@ -27,6 +27,10 @@ public static function messages(): iterable . 'context and argument-free source traces. Existing snapshots cannot recover missing data. ' . 'Listeners, their durations, and final propagation results are not captured.', ]; + yield 'classes_suffix' => [ + EventMessage::CLASSES_SUFFIX, + ' classes', + ]; yield 'context_captured' => [ EventMessage::CONTEXT_CAPTURED, 'Selected context at observation time', @@ -43,6 +47,10 @@ public static function messages(): iterable EventMessage::CONTEXT_UNSUPPORTED, 'No context extractor for this event type', ]; + yield 'event' => [ + EventMessage::EVENT, + 'Event', + ]; yield 'empty_call_to_action' => [ EventMessage::EMPTY_CALL_TO_ACTION, 'Dispatch an application event to populate this view:', @@ -60,6 +68,10 @@ public static function messages(): iterable EventMessage::EMPTY_HEADLINE, 'No events dispatched in this request', ]; + yield 'events_suffix' => [ + EventMessage::EVENTS_SUFFIX, + ' events', + ]; yield 'first_observation' => [ EventMessage::FIRST_OBSERVATION, 'First observation', @@ -85,6 +97,18 @@ public static function messages(): iterable EventMessage::NO_MATCH_HEADLINE, 'No events match the active filters', ]; + yield 'number' => [ + EventMessage::NUMBER, + '#', + ]; + yield 'static_suffix' => [ + EventMessage::STATIC_SUFFIX, + ' static', + ]; + yield 'time' => [ + EventMessage::TIME, + 'Time', + ]; yield 'timing_guidance' => [ EventMessage::TIMING_GUIDANCE, 'Offsets are relative to the first captured event. Gaps are not listener durations. ' diff --git a/tests/Provider/LogMessageProvider.php b/tests/Provider/LogMessageProvider.php index 5911ef3..5a49048 100644 --- a/tests/Provider/LogMessageProvider.php +++ b/tests/Provider/LogMessageProvider.php @@ -17,6 +17,22 @@ final class LogMessageProvider */ public static function messages(): iterable { + yield 'category' => [ + LogMessage::CATEGORY, + 'Category', + ]; + yield 'chip_aria' => [ + LogMessage::CHIP_ARIA, + '%d %s; filter log messages by %s level', + ]; + yield 'chip_title' => [ + LogMessage::CHIP_TITLE, + 'Show only %s log messages', + ]; + yield 'delta' => [ + LogMessage::DELTA, + 'Delta', + ]; yield 'empty_explanation' => [ LogMessage::EMPTY_EXPLANATION, 'This request did not emit log messages through the debug log target.', @@ -25,6 +41,58 @@ public static function messages(): iterable LogMessage::EMPTY_HEADLINE, 'No log messages captured', ]; + yield 'filter_error' => [ + LogMessage::FILTER_ERROR, + 'Error', + ]; + yield 'filter_info' => [ + LogMessage::FILTER_INFO, + 'Info', + ]; + yield 'filter_trace' => [ + LogMessage::FILTER_TRACE, + 'Trace', + ]; + yield 'filter_warning' => [ + LogMessage::FILTER_WARNING, + 'Warning', + ]; + yield 'level' => [ + LogMessage::LEVEL, + 'Level', + ]; + yield 'level_error' => [ + LogMessage::LEVEL_ERROR, + 'error', + ]; + yield 'level_errors' => [ + LogMessage::LEVEL_ERRORS, + 'errors', + ]; + yield 'level_info' => [ + LogMessage::LEVEL_INFO, + 'info', + ]; + yield 'level_trace' => [ + LogMessage::LEVEL_TRACE, + 'trace', + ]; + yield 'level_warning' => [ + LogMessage::LEVEL_WARNING, + 'warning', + ]; + yield 'level_warnings' => [ + LogMessage::LEVEL_WARNINGS, + 'warnings', + ]; + yield 'message' => [ + LogMessage::MESSAGE, + 'Message', + ]; + yield 'messages_suffix' => [ + LogMessage::MESSAGES_SUFFIX, + ' messages', + ]; yield 'no_match_explanation' => [ LogMessage::NO_MATCH_EXPLANATION, 'Adjust or clear the filters to show the captured messages.', @@ -33,5 +101,21 @@ public static function messages(): iterable LogMessage::NO_MATCH_HEADLINE, 'No log messages match the active filters', ]; + yield 'number' => [ + LogMessage::NUMBER, + '#', + ]; + yield 'time' => [ + LogMessage::TIME, + 'Time', + ]; + yield 'toolbar_errors' => [ + LogMessage::TOOLBAR_ERRORS, + 'Errors', + ]; + yield 'toolbar_warnings' => [ + LogMessage::TOOLBAR_WARNINGS, + 'Warnings', + ]; } } diff --git a/tests/Provider/ProfileMessageProvider.php b/tests/Provider/ProfileMessageProvider.php index 485224a..841096f 100644 --- a/tests/Provider/ProfileMessageProvider.php +++ b/tests/Provider/ProfileMessageProvider.php @@ -17,6 +17,22 @@ final class ProfileMessageProvider */ public static function messages(): iterable { + yield 'apply' => [ + ProfileMessage::APPLY, + 'Apply', + ]; + yield 'category' => [ + ProfileMessage::CATEGORY, + 'Category', + ]; + yield 'category_placeholder' => [ + ProfileMessage::CATEGORY_PLACEHOLDER, + 'yii\\db\\Command::query', + ]; + yield 'details' => [ + ProfileMessage::DETAILS, + 'Details', + ]; yield 'empty_call_to_action' => [ ProfileMessage::EMPTY_CALL_TO_ACTION, 'To populate this view, wrap interesting sections of code with profile markers:', @@ -33,6 +49,34 @@ public static function messages(): iterable ProfileMessage::EMPTY_HEADLINE, 'No profiling data captured', ]; + yield 'empty_no_spans' => [ + ProfileMessage::EMPTY_NO_SPANS, + ' spans, so the Timeline and details are empty.', + ]; + yield 'empty_produced' => [ + ProfileMessage::EMPTY_PRODUCED, + 'This request did not produce any ', + ]; + yield 'empty_separator' => [ + ProfileMessage::EMPTY_SEPARATOR, + ' / ', + ]; + yield 'filters' => [ + ProfileMessage::FILTERS, + 'Profiling filters', + ]; + yield 'info' => [ + ProfileMessage::INFO, + 'Info', + ]; + yield 'info_placeholder' => [ + ProfileMessage::INFO_PLACEHOLDER, + 'SELECT', + ]; + yield 'min_duration' => [ + ProfileMessage::MIN_DURATION, + 'Min duration (ms)', + ]; yield 'no_match_explanation' => [ ProfileMessage::NO_MATCH_EXPLANATION, 'Adjust or clear the filters to show the captured spans.', @@ -41,6 +85,18 @@ public static function messages(): iterable ProfileMessage::NO_MATCH_HEADLINE, 'No spans match the active filters', ]; + yield 'peak_suffix' => [ + ProfileMessage::PEAK_SUFFIX, + ' peak', + ]; + yield 'span_suffix' => [ + ProfileMessage::SPAN_SUFFIX, + ' span', + ]; + yield 'spans_suffix' => [ + ProfileMessage::SPANS_SUFFIX, + ' spans', + ]; yield 'timeline_unavailable_details' => [ ProfileMessage::TIMELINE_UNAVAILABLE_DETAILS, 'The profiling details remain available below.', @@ -54,5 +110,17 @@ public static function messages(): iterable ProfileMessage::TIMELINE_UNAVAILABLE_HEADLINE, 'Timeline unavailable', ]; + yield 'toolbar_memory' => [ + ProfileMessage::TOOLBAR_MEMORY, + 'Peak memory', + ]; + yield 'toolbar_time' => [ + ProfileMessage::TOOLBAR_TIME, + 'Total processing time', + ]; + yield 'total_suffix' => [ + ProfileMessage::TOTAL_SUFFIX, + ' total', + ]; } }