fix(error): harden logging, i18n, a11y and handler tests
This commit is contained in:
@@ -218,6 +218,44 @@
|
||||
"Failed to send access emails": "Zugangsdaten konnten nicht gesendet werden",
|
||||
"Read": "Gelesen",
|
||||
"Home": "Home",
|
||||
"Exception": "Ausnahme",
|
||||
"An error occurred": "Ein Fehler ist aufgetreten",
|
||||
"in %s on line %s": "in %s in Zeile %s",
|
||||
"Copy request ID": "Request-ID kopieren",
|
||||
"Error details": "Fehlerdetails",
|
||||
"Request": "Anfrage",
|
||||
"Queries": "Abfragen",
|
||||
"An unexpected error occurred. If the problem persists, please contact support with the reference below.": "Ein unerwarteter Fehler ist aufgetreten. Wenn das Problem weiterhin besteht, kontaktiere bitte den Support mit der untenstehenden Referenz.",
|
||||
"Stack Trace": "Stack-Trace",
|
||||
"Caused by": "Verursacht durch",
|
||||
"URL": "URL",
|
||||
"User Agent": "User-Agent",
|
||||
"Headers": "Header",
|
||||
"GET Parameters": "GET-Parameter",
|
||||
"POST Parameters": "POST-Parameter",
|
||||
"Session (keys + types)": "Session (Schlüssel + Typen)",
|
||||
"Context": "Kontext",
|
||||
"PHP": "PHP",
|
||||
"SAPI": "SAPI",
|
||||
"Memory (current)": "Speicher (aktuell)",
|
||||
"Memory (peak)": "Speicher (maximal)",
|
||||
"Application": "Anwendung",
|
||||
"Active Modules": "Aktive Module",
|
||||
"Loaded Extensions (%d)": "Geladene Erweiterungen (%d)",
|
||||
"Query tracking is not available (Debugger disabled or not initialized).": "Query-Tracking ist nicht verfügbar (Debugger deaktiviert oder nicht initialisiert).",
|
||||
"No queries were executed before the error occurred.": "Vor dem Fehler wurden keine Abfragen ausgeführt.",
|
||||
"Queries (%d)": "Abfragen (%d)",
|
||||
"Bad Request": "Ungültige Anfrage",
|
||||
"Unauthorized": "Nicht autorisiert",
|
||||
"Forbidden": "Verboten",
|
||||
"Not Found": "Nicht gefunden",
|
||||
"Method Not Allowed": "Methode nicht erlaubt",
|
||||
"Request Timeout": "Zeitüberschreitung der Anfrage",
|
||||
"Unprocessable Entity": "Nicht verarbeitbare Anfrage",
|
||||
"Too Many Requests": "Zu viele Anfragen",
|
||||
"Internal Server Error": "Interner Serverfehler",
|
||||
"Bad Gateway": "Fehlerhaftes Gateway",
|
||||
"Service Unavailable": "Dienst nicht verfügbar",
|
||||
"Users": "Benutzer",
|
||||
"All users": "Alle Benutzer",
|
||||
"Avatar": "Avatar",
|
||||
|
||||
@@ -218,6 +218,44 @@
|
||||
"Failed to send access emails": "Failed to send access emails",
|
||||
"Read": "Read",
|
||||
"Home": "Home",
|
||||
"Exception": "Exception",
|
||||
"An error occurred": "An error occurred",
|
||||
"in %s on line %s": "in %s on line %s",
|
||||
"Copy request ID": "Copy request ID",
|
||||
"Error details": "Error details",
|
||||
"Request": "Request",
|
||||
"Queries": "Queries",
|
||||
"An unexpected error occurred. If the problem persists, please contact support with the reference below.": "An unexpected error occurred. If the problem persists, please contact support with the reference below.",
|
||||
"Stack Trace": "Stack Trace",
|
||||
"Caused by": "Caused by",
|
||||
"URL": "URL",
|
||||
"User Agent": "User Agent",
|
||||
"Headers": "Headers",
|
||||
"GET Parameters": "GET Parameters",
|
||||
"POST Parameters": "POST Parameters",
|
||||
"Session (keys + types)": "Session (keys + types)",
|
||||
"Context": "Context",
|
||||
"PHP": "PHP",
|
||||
"SAPI": "SAPI",
|
||||
"Memory (current)": "Memory (current)",
|
||||
"Memory (peak)": "Memory (peak)",
|
||||
"Application": "Application",
|
||||
"Active Modules": "Active Modules",
|
||||
"Loaded Extensions (%d)": "Loaded Extensions (%d)",
|
||||
"Query tracking is not available (Debugger disabled or not initialized).": "Query tracking is not available (Debugger disabled or not initialized).",
|
||||
"No queries were executed before the error occurred.": "No queries were executed before the error occurred.",
|
||||
"Queries (%d)": "Queries (%d)",
|
||||
"Bad Request": "Bad Request",
|
||||
"Unauthorized": "Unauthorized",
|
||||
"Forbidden": "Forbidden",
|
||||
"Not Found": "Not Found",
|
||||
"Method Not Allowed": "Method Not Allowed",
|
||||
"Request Timeout": "Request Timeout",
|
||||
"Unprocessable Entity": "Unprocessable Entity",
|
||||
"Too Many Requests": "Too Many Requests",
|
||||
"Internal Server Error": "Internal Server Error",
|
||||
"Bad Gateway": "Bad Gateway",
|
||||
"Service Unavailable": "Service Unavailable",
|
||||
"Users": "Users",
|
||||
"All users": "All users",
|
||||
"Avatar": "Avatar",
|
||||
|
||||
@@ -40,8 +40,10 @@ final class ErrorHandler
|
||||
self::$reservedMemory = null;
|
||||
|
||||
// Discard any partial output from the failed request.
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
if (!self::isPhpUnitRuntime()) {
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
}
|
||||
|
||||
// Set HTTP 500 if no error status has been set yet.
|
||||
@@ -187,4 +189,9 @@ final class ErrorHandler
|
||||
echo "\n" . $exception->getMessage() . "\n" . $exception->getTraceAsString() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
private static function isPhpUnitRuntime(): bool
|
||||
{
|
||||
return defined('PHPUNIT_COMPOSER_INSTALL') || defined('__PHPUNIT_PHAR__');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ final class ErrorLogger
|
||||
private const LOG_DIR = 'storage/logs/errors';
|
||||
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
private const MAX_ROTATED_FILES = 5;
|
||||
private const REDACTION_MARKER = '[REDACTED]';
|
||||
|
||||
public static function log(\Throwable $exception, array $collectedData): void
|
||||
{
|
||||
@@ -77,6 +78,7 @@ final class ErrorLogger
|
||||
$exceptionData = $collectedData['exception'] ?? [];
|
||||
$requestData = $collectedData['request'] ?? [];
|
||||
$envData = $collectedData['environment'] ?? [];
|
||||
$exceptionMessage = (string) ($exceptionData['message'] ?? $exception->getMessage());
|
||||
|
||||
$traceSummary = '';
|
||||
$frames = $exceptionData['trace'] ?? [];
|
||||
@@ -93,7 +95,8 @@ final class ErrorLogger
|
||||
'channel' => $requestData['channel'] ?? 'web',
|
||||
'exception' => [
|
||||
'class' => $exceptionData['class'] ?? get_class($exception),
|
||||
'message' => $exceptionData['message'] ?? $exception->getMessage(),
|
||||
'message' => self::REDACTION_MARKER,
|
||||
'message_hash' => self::hashMessage($exceptionMessage),
|
||||
'code' => $exceptionData['code'] ?? $exception->getCode(),
|
||||
'file' => self::shortenPath($exceptionData['file'] ?? $exception->getFile()),
|
||||
'line' => $exceptionData['line'] ?? $exception->getLine(),
|
||||
@@ -101,7 +104,7 @@ final class ErrorLogger
|
||||
'trace_summary' => trim($traceSummary),
|
||||
'request' => [
|
||||
'method' => $requestData['method'] ?? null,
|
||||
'url' => $requestData['url'] ?? null,
|
||||
'path' => self::normalizeRequestPath($requestData['url'] ?? null),
|
||||
'ip_hash' => self::hashIp($requestData['ip'] ?? null),
|
||||
'user_id' => $requestData['user_id'] ?? null,
|
||||
'tenant_id' => $requestData['tenant_id'] ?? null,
|
||||
@@ -120,6 +123,40 @@ final class ErrorLogger
|
||||
return 'sha256:' . hash('sha256', $ip);
|
||||
}
|
||||
|
||||
private static function hashMessage(string $message): ?string
|
||||
{
|
||||
$message = trim($message);
|
||||
if ($message === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'sha256:' . hash('sha256', $message);
|
||||
}
|
||||
|
||||
private static function normalizeRequestPath(mixed $rawUrl): ?string
|
||||
{
|
||||
if (!is_string($rawUrl) || trim($rawUrl) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = parse_url($rawUrl, PHP_URL_PATH);
|
||||
if (!is_string($path) || trim($path) === '') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
$path = preg_replace('/[[:cntrl:]]+/', '', $path) ?? '';
|
||||
$path = trim($path);
|
||||
if ($path === '') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
if (!str_starts_with($path, '/')) {
|
||||
$path = '/' . $path;
|
||||
}
|
||||
|
||||
return substr($path, 0, 500);
|
||||
}
|
||||
|
||||
private static function shortenPath(string $path): string
|
||||
{
|
||||
$root = getcwd();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\I18n;
|
||||
|
||||
final class ErrorRenderer
|
||||
{
|
||||
/**
|
||||
@@ -24,23 +26,37 @@ final class ErrorRenderer
|
||||
$css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css');
|
||||
$js = self::loadAsset(__DIR__ . '/../../web/js/components/app-error-debug.js');
|
||||
|
||||
$excClass = self::esc($exc['class'] ?? 'Exception');
|
||||
$excMessage = self::esc($exc['message'] ?? 'An error occurred');
|
||||
$excFile = self::esc(self::shortenPath($exc['file'] ?? ''));
|
||||
$excLine = self::esc((string) ($exc['line'] ?? '?'));
|
||||
$excClass = self::esc($exc['class'] ?? self::tr('Exception'));
|
||||
$excMessage = self::esc((string) ($exc['message'] ?? self::tr('An error occurred')));
|
||||
$excLocation = self::esc(self::tr(
|
||||
'in %s on line %s',
|
||||
self::shortenPath((string) ($exc['file'] ?? '')),
|
||||
(string) ($exc['line'] ?? '?')
|
||||
));
|
||||
|
||||
$exceptionPanel = self::renderExceptionPanel($exc);
|
||||
$requestPanel = self::renderRequestPanel($req);
|
||||
$environmentPanel = self::renderEnvironmentPanel($env);
|
||||
$queryPanel = self::renderQueryPanel($qry);
|
||||
|
||||
$lang = self::esc(self::htmlLang());
|
||||
$statusTextEsc = self::esc($statusText);
|
||||
$requestIdLabel = self::esc(self::tr('Request ID'));
|
||||
$copyRequestIdTitle = self::esc(self::tr('Copy request ID'));
|
||||
$tabsLabel = self::esc(self::tr('Error details'));
|
||||
|
||||
$tabException = self::esc(self::tr('Exception'));
|
||||
$tabRequest = self::esc(self::tr('Request'));
|
||||
$tabEnvironment = self::esc(self::tr('Environment'));
|
||||
$tabQueries = self::esc(self::tr('Queries'));
|
||||
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{$lang}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{$statusCode} {$statusText}</title>
|
||||
<title>{$statusCode} {$statusTextEsc}</title>
|
||||
<style>{$css}</style>
|
||||
</head>
|
||||
<body class="app-error-debug">
|
||||
@@ -48,11 +64,11 @@ final class ErrorRenderer
|
||||
<div class="app-error-debug-container app-error-debug-header__inner">
|
||||
<div class="app-error-debug-header__status">
|
||||
<span class="app-error-debug-header__code">{$statusCode}</span>
|
||||
<span class="app-error-debug-header__label">{$statusText}</span>
|
||||
<span class="app-error-debug-header__label">{$statusTextEsc}</span>
|
||||
</div>
|
||||
<div class="app-error-debug-request-id">
|
||||
Request ID: {$requestId}
|
||||
<button class="app-error-debug-copy-button" data-copy="{$requestId}" title="Copy request ID">
|
||||
{$requestIdLabel}: {$requestId}
|
||||
<button type="button" class="app-error-debug-copy-button" data-copy="{$requestId}" title="{$copyRequestIdTitle}" aria-label="{$copyRequestIdTitle}">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -63,20 +79,20 @@ final class ErrorRenderer
|
||||
<div class="app-error-debug-summary">
|
||||
<div class="app-error-debug-summary__class">{$excClass}</div>
|
||||
<div class="app-error-debug-summary__message">{$excMessage}</div>
|
||||
<div class="app-error-debug-summary__location">in {$excFile} on line {$excLine}</div>
|
||||
<div class="app-error-debug-summary__location">{$excLocation}</div>
|
||||
</div>
|
||||
|
||||
<nav class="app-error-debug-tabs">
|
||||
<button class="app-error-debug-tab active" data-panel="panel-exception">Exception</button>
|
||||
<button class="app-error-debug-tab" data-panel="panel-request">Request</button>
|
||||
<button class="app-error-debug-tab" data-panel="panel-environment">Environment</button>
|
||||
<button class="app-error-debug-tab" data-panel="panel-queries">Queries <span class="app-error-debug-tab__count">{$queryCount}</span></button>
|
||||
<nav class="app-error-debug-tabs" role="tablist" aria-label="{$tabsLabel}">
|
||||
<button type="button" id="tab-exception" class="app-error-debug-tab active" role="tab" aria-selected="true" aria-controls="panel-exception" data-panel="panel-exception" tabindex="0">{$tabException}</button>
|
||||
<button type="button" id="tab-request" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-request" data-panel="panel-request" tabindex="-1">{$tabRequest}</button>
|
||||
<button type="button" id="tab-environment" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-environment" data-panel="panel-environment" tabindex="-1">{$tabEnvironment}</button>
|
||||
<button type="button" id="tab-queries" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-queries" data-panel="panel-queries" tabindex="-1">{$tabQueries} <span class="app-error-debug-tab__count">{$queryCount}</span></button>
|
||||
</nav>
|
||||
|
||||
<div id="panel-exception" class="app-error-debug-panel active">{$exceptionPanel}</div>
|
||||
<div id="panel-request" class="app-error-debug-panel">{$requestPanel}</div>
|
||||
<div id="panel-environment" class="app-error-debug-panel">{$environmentPanel}</div>
|
||||
<div id="panel-queries" class="app-error-debug-panel">{$queryPanel}</div>
|
||||
<div id="panel-exception" class="app-error-debug-panel active" role="tabpanel" aria-labelledby="tab-exception">{$exceptionPanel}</div>
|
||||
<div id="panel-request" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-request">{$requestPanel}</div>
|
||||
<div id="panel-environment" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-environment">{$environmentPanel}</div>
|
||||
<div id="panel-queries" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-queries">{$queryPanel}</div>
|
||||
</main>
|
||||
|
||||
<script>{$js}</script>
|
||||
@@ -94,14 +110,22 @@ final class ErrorRenderer
|
||||
$statusCode = http_response_code() ?: 500;
|
||||
$requestId = self::esc($requestId);
|
||||
$css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css');
|
||||
$js = self::loadAsset(__DIR__ . '/../../web/js/components/app-error-debug.js');
|
||||
|
||||
$lang = self::esc(self::htmlLang());
|
||||
$errorText = self::esc(self::tr('Error'));
|
||||
$requestIdLabel = self::esc(self::tr('Request ID'));
|
||||
$copyRequestIdTitle = self::esc(self::tr('Copy request ID'));
|
||||
$message = self::esc(self::tr('An unexpected error occurred. If the problem persists, please contact support with the reference below.'));
|
||||
$backLabel = self::esc(self::tr('Back to start'));
|
||||
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{$lang}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{$statusCode} - Error</title>
|
||||
<title>{$statusCode} - {$errorText}</title>
|
||||
<style>
|
||||
{$css}
|
||||
.app-error-prod {
|
||||
@@ -130,13 +154,14 @@ final class ErrorRenderer
|
||||
<body>
|
||||
<div class="app-error-prod">
|
||||
<div class="app-error-prod__code">{$statusCode}</div>
|
||||
<div class="app-error-prod__message">An unexpected error occurred. If the problem persists, please contact support with the reference below.</div>
|
||||
<div class="app-error-prod__message">{$message}</div>
|
||||
<div class="app-error-debug-request-id" style="margin-top: calc(var(--app-spacing) * 1.5);">
|
||||
{$requestId}
|
||||
<button class="app-error-debug-copy-button" onclick="navigator.clipboard?.writeText('{$requestId}')" title="Copy"><svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg></button>
|
||||
{$requestIdLabel}: {$requestId}
|
||||
<button type="button" class="app-error-debug-copy-button" data-copy="{$requestId}" title="{$copyRequestIdTitle}" aria-label="{$copyRequestIdTitle}"><svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg></button>
|
||||
</div>
|
||||
<a class="app-error-prod__back" href="/">Back to start</a>
|
||||
<a class="app-error-prod__back" href="/">{$backLabel}</a>
|
||||
</div>
|
||||
<script>{$js}</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
@@ -144,18 +169,18 @@ final class ErrorRenderer
|
||||
|
||||
private static function renderExceptionPanel(array $exceptionData): string
|
||||
{
|
||||
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">Stack Trace</div>';
|
||||
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">' . self::esc(self::tr('Stack Trace')) . '</div>';
|
||||
$html .= '<ol class="app-error-debug-trace">';
|
||||
|
||||
$frames = $exceptionData['trace'] ?? [];
|
||||
foreach ($frames as $i => $frame) {
|
||||
$file = self::esc(self::shortenPath($frame['file'] ?? '[internal]'));
|
||||
$file = self::esc(self::shortenPath((string) ($frame['file'] ?? '[internal]')));
|
||||
$line = self::esc((string) ($frame['line'] ?? '?'));
|
||||
$function = '';
|
||||
if (isset($frame['class'])) {
|
||||
$function = self::esc($frame['class'] . '::' . ($frame['function'] ?? '?'));
|
||||
$function = self::esc((string) $frame['class'] . '::' . (string) ($frame['function'] ?? '?'));
|
||||
} elseif (isset($frame['function'])) {
|
||||
$function = self::esc($frame['function']);
|
||||
$function = self::esc((string) $frame['function']);
|
||||
}
|
||||
|
||||
$codeHtml = '';
|
||||
@@ -165,14 +190,20 @@ final class ErrorRenderer
|
||||
$codeHtml = self::renderCodeBlock($snippet, $highlightLine);
|
||||
}
|
||||
|
||||
$codeId = 'frame-code-' . $i;
|
||||
$toggleId = 'frame-toggle-' . $i;
|
||||
$isOpen = ($i === 0);
|
||||
$expanded = $isOpen ? 'true' : 'false';
|
||||
$openClass = $isOpen ? ' open' : '';
|
||||
|
||||
$html .= <<<FRAME
|
||||
<li class="app-error-debug-frame">
|
||||
<div class="app-error-debug-frame__header">
|
||||
<li class="app-error-debug-frame{$openClass}">
|
||||
<button type="button" id="{$toggleId}" class="app-error-debug-frame__header app-error-debug-frame__toggle" aria-expanded="{$expanded}" aria-controls="{$codeId}">
|
||||
<span class="app-error-debug-frame__index">#{$i}</span>
|
||||
<span class="app-error-debug-frame__file">{$file}</span>:<span class="app-error-debug-frame__line">{$line}</span>
|
||||
<span class="app-error-debug-frame__function">{$function}</span>
|
||||
</div>
|
||||
<div class="app-error-debug-frame__code">{$codeHtml}</div>
|
||||
</button>
|
||||
<div id="{$codeId}" class="app-error-debug-frame__code" role="region" aria-labelledby="{$toggleId}">{$codeHtml}</div>
|
||||
</li>
|
||||
FRAME;
|
||||
}
|
||||
@@ -183,10 +214,14 @@ final class ErrorRenderer
|
||||
if (isset($exceptionData['previous'])) {
|
||||
$prev = $exceptionData['previous'];
|
||||
$html .= '<div class="app-error-debug-chain">';
|
||||
$html .= '<div class="app-error-debug-chain__label">Caused by</div>';
|
||||
$html .= '<div class="app-error-debug-summary__class">' . self::esc($prev['class'] ?? 'Exception') . '</div>';
|
||||
$html .= '<div class="app-error-debug-summary__message">' . self::esc($prev['message'] ?? '') . '</div>';
|
||||
$html .= '<div class="app-error-debug-summary__location">in ' . self::esc(self::shortenPath($prev['file'] ?? '')) . ' on line ' . self::esc((string) ($prev['line'] ?? '?')) . '</div>';
|
||||
$html .= '<div class="app-error-debug-chain__label">' . self::esc(self::tr('Caused by')) . '</div>';
|
||||
$html .= '<div class="app-error-debug-summary__class">' . self::esc((string) ($prev['class'] ?? self::tr('Exception'))) . '</div>';
|
||||
$html .= '<div class="app-error-debug-summary__message">' . self::esc((string) ($prev['message'] ?? '')) . '</div>';
|
||||
$html .= '<div class="app-error-debug-summary__location">' . self::esc(self::tr(
|
||||
'in %s on line %s',
|
||||
self::shortenPath((string) ($prev['file'] ?? '')),
|
||||
(string) ($prev['line'] ?? '?')
|
||||
)) . '</div>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
@@ -214,43 +249,43 @@ final class ErrorRenderer
|
||||
{
|
||||
$html = '';
|
||||
|
||||
$html .= self::renderInfoSection('Request', [
|
||||
'Method' => $requestData['method'] ?? 'GET',
|
||||
'URL' => $requestData['url'] ?? '',
|
||||
'IP' => $requestData['ip'] ?? '',
|
||||
'User Agent' => $requestData['user_agent'] ?? '',
|
||||
'Channel' => $requestData['channel'] ?? 'web',
|
||||
$html .= self::renderInfoSection(self::tr('Request'), [
|
||||
self::tr('Method') => $requestData['method'] ?? 'GET',
|
||||
self::tr('URL') => $requestData['url'] ?? '',
|
||||
self::tr('IP') => $requestData['ip'] ?? '',
|
||||
self::tr('User Agent') => $requestData['user_agent'] ?? '',
|
||||
self::tr('Channel') => $requestData['channel'] ?? 'web',
|
||||
]);
|
||||
|
||||
$headers = $requestData['headers'] ?? [];
|
||||
if (!empty($headers)) {
|
||||
$html .= self::renderInfoSection('Headers', $headers);
|
||||
$html .= self::renderInfoSection(self::tr('Headers'), $headers);
|
||||
}
|
||||
|
||||
$get = $requestData['get'] ?? [];
|
||||
if (!empty($get)) {
|
||||
$html .= self::renderInfoSection('GET Parameters', array_map(
|
||||
fn ($v): string => is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_SLASHES),
|
||||
$html .= self::renderInfoSection(self::tr('GET Parameters'), array_map(
|
||||
fn ($v): string => is_string($v) ? $v : (string) json_encode($v, JSON_UNESCAPED_SLASHES),
|
||||
$get,
|
||||
));
|
||||
}
|
||||
|
||||
$post = $requestData['post'] ?? [];
|
||||
if (!empty($post)) {
|
||||
$html .= self::renderInfoSection('POST Parameters', array_map(
|
||||
fn ($v): string => is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_SLASHES),
|
||||
$html .= self::renderInfoSection(self::tr('POST Parameters'), array_map(
|
||||
fn ($v): string => is_string($v) ? $v : (string) json_encode($v, JSON_UNESCAPED_SLASHES),
|
||||
$post,
|
||||
));
|
||||
}
|
||||
|
||||
$session = $requestData['session_keys'] ?? [];
|
||||
if (!empty($session)) {
|
||||
$html .= self::renderInfoSection('Session (keys + types)', $session);
|
||||
$html .= self::renderInfoSection(self::tr('Session (keys + types)'), $session);
|
||||
}
|
||||
|
||||
$html .= self::renderInfoSection('Context', array_filter([
|
||||
'User ID' => $requestData['user_id'] ?? null,
|
||||
'Tenant ID' => $requestData['tenant_id'] ?? null,
|
||||
$html .= self::renderInfoSection(self::tr('Context'), array_filter([
|
||||
self::tr('User ID') => $requestData['user_id'] ?? null,
|
||||
self::tr('Tenant ID') => $requestData['tenant_id'] ?? null,
|
||||
], fn ($v): bool => $v !== null));
|
||||
|
||||
return $html;
|
||||
@@ -260,29 +295,31 @@ final class ErrorRenderer
|
||||
{
|
||||
$html = '';
|
||||
|
||||
$html .= self::renderInfoSection('PHP', [
|
||||
'Version' => $envData['php_version'] ?? PHP_VERSION,
|
||||
'SAPI' => $envData['php_sapi'] ?? PHP_SAPI,
|
||||
'Memory (current)' => self::formatBytes($envData['memory_current'] ?? 0),
|
||||
'Memory (peak)' => self::formatBytes($envData['memory_peak'] ?? 0),
|
||||
$html .= self::renderInfoSection(self::tr('PHP'), [
|
||||
self::tr('Version') => $envData['php_version'] ?? PHP_VERSION,
|
||||
self::tr('SAPI') => $envData['php_sapi'] ?? PHP_SAPI,
|
||||
self::tr('Memory (current)') => self::formatBytes((int) ($envData['memory_current'] ?? 0)),
|
||||
self::tr('Memory (peak)') => self::formatBytes((int) ($envData['memory_peak'] ?? 0)),
|
||||
]);
|
||||
|
||||
$env = $envData['env'] ?? [];
|
||||
if (!empty($env)) {
|
||||
$html .= self::renderInfoSection('Application', $env);
|
||||
$html .= self::renderInfoSection(self::tr('Application'), $env);
|
||||
}
|
||||
|
||||
$modules = $envData['modules'] ?? [];
|
||||
if (!empty($modules)) {
|
||||
$html .= self::renderInfoSection('Active Modules', array_combine(
|
||||
$html .= self::renderInfoSection(self::tr('Active Modules'), array_combine(
|
||||
array_map(fn ($m): string => (string) $m, $modules),
|
||||
array_fill(0, count($modules), 'active'),
|
||||
array_fill(0, count($modules), self::tr('Active')),
|
||||
) ?: []);
|
||||
}
|
||||
|
||||
$extensions = $envData['extensions'] ?? [];
|
||||
if (!empty($extensions)) {
|
||||
$html .= '<div class="app-error-debug-section"><div class="app-error-debug-section__title">Loaded Extensions (' . count($extensions) . ')</div>';
|
||||
$html .= '<div class="app-error-debug-section"><div class="app-error-debug-section__title">'
|
||||
. self::esc(self::tr('Loaded Extensions (%d)', count($extensions)))
|
||||
. '</div>';
|
||||
$html .= '<div style="font-family: var(--app-font-family-monospace); font-size: var(--text-xs); color: var(--app-muted-color); padding: calc(var(--app-spacing) * 0.5); background: var(--app-card-background-color); border-radius: var(--app-border-radius); border: 1px solid var(--app-border);">';
|
||||
$html .= self::esc(implode(', ', $extensions));
|
||||
$html .= '</div></div>';
|
||||
@@ -294,15 +331,17 @@ final class ErrorRenderer
|
||||
private static function renderQueryPanel(array $queryData): string
|
||||
{
|
||||
if (!($queryData['available'] ?? false)) {
|
||||
return '<div class="app-error-debug-empty">Query tracking is not available (Debugger disabled or not initialized).</div>';
|
||||
return '<div class="app-error-debug-empty">' . self::esc(self::tr('Query tracking is not available (Debugger disabled or not initialized).')) . '</div>';
|
||||
}
|
||||
|
||||
$queries = $queryData['queries'] ?? [];
|
||||
if (empty($queries)) {
|
||||
return '<div class="app-error-debug-empty">No queries were executed before the error occurred.</div>';
|
||||
return '<div class="app-error-debug-empty">' . self::esc(self::tr('No queries were executed before the error occurred.')) . '</div>';
|
||||
}
|
||||
|
||||
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">Queries (' . count($queries) . ')</div>';
|
||||
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">'
|
||||
. self::esc(self::tr('Queries (%d)', count($queries)))
|
||||
. '</div>';
|
||||
|
||||
foreach ($queries as $i => $query) {
|
||||
$sql = is_string($query) ? $query : ($query[0] ?? $query['query'] ?? '?');
|
||||
@@ -312,14 +351,17 @@ final class ErrorRenderer
|
||||
$duration = $durationMs . ' ms';
|
||||
}
|
||||
|
||||
$querySqlId = 'query-sql-' . $i;
|
||||
$queryToggleId = 'query-toggle-' . $i;
|
||||
|
||||
$html .= '<div class="app-error-debug-query">';
|
||||
$html .= '<div class="app-error-debug-query__header">';
|
||||
$html .= '<button type="button" id="' . self::esc($queryToggleId) . '" class="app-error-debug-query__header app-error-debug-query__toggle" aria-expanded="false" aria-controls="' . self::esc($querySqlId) . '">';
|
||||
$html .= '<span>' . self::esc(self::truncate($sql, 120)) . '</span>';
|
||||
if ($duration !== '') {
|
||||
$html .= '<span class="app-error-debug-query__duration">' . self::esc($duration) . '</span>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
$html .= '<div class="app-error-debug-query__sql">' . self::esc($sql) . '</div>';
|
||||
$html .= '</button>';
|
||||
$html .= '<div id="' . self::esc($querySqlId) . '" class="app-error-debug-query__sql" role="region" aria-labelledby="' . self::esc($queryToggleId) . '">' . self::esc($sql) . '</div>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
@@ -383,18 +425,18 @@ final class ErrorRenderer
|
||||
private static function statusText(int $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
400 => 'Bad Request',
|
||||
401 => 'Unauthorized',
|
||||
403 => 'Forbidden',
|
||||
404 => 'Not Found',
|
||||
405 => 'Method Not Allowed',
|
||||
408 => 'Request Timeout',
|
||||
422 => 'Unprocessable Entity',
|
||||
429 => 'Too Many Requests',
|
||||
500 => 'Internal Server Error',
|
||||
502 => 'Bad Gateway',
|
||||
503 => 'Service Unavailable',
|
||||
default => 'Error',
|
||||
400 => self::tr('Bad Request'),
|
||||
401 => self::tr('Unauthorized'),
|
||||
403 => self::tr('Forbidden'),
|
||||
404 => self::tr('Not Found'),
|
||||
405 => self::tr('Method Not Allowed'),
|
||||
408 => self::tr('Request Timeout'),
|
||||
422 => self::tr('Unprocessable Entity'),
|
||||
429 => self::tr('Too Many Requests'),
|
||||
500 => self::tr('Internal Server Error'),
|
||||
502 => self::tr('Bad Gateway'),
|
||||
503 => self::tr('Service Unavailable'),
|
||||
default => self::tr('Error'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -418,4 +460,42 @@ final class ErrorRenderer
|
||||
|
||||
return mb_substr($text, 0, $length) . '...';
|
||||
}
|
||||
|
||||
private static function tr(string $text, mixed ...$args): string
|
||||
{
|
||||
if (function_exists('t')) {
|
||||
try {
|
||||
/** @var string $translated */
|
||||
$translated = t($text, ...$args);
|
||||
return $translated;
|
||||
} catch (\Throwable) {
|
||||
// Rendering must stay resilient, even if translation bootstrap failed.
|
||||
}
|
||||
}
|
||||
|
||||
if ($args === []) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return (string) @vsprintf($text, $args);
|
||||
}
|
||||
|
||||
private static function htmlLang(): string
|
||||
{
|
||||
$locale = trim((string) (I18n::$locale ?? I18n::$defaultLocale ?? 'en'));
|
||||
if ($locale === '') {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
$locale = strtolower(str_replace('_', '-', $locale));
|
||||
if (preg_match('/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/', $locale) === 1) {
|
||||
return $locale;
|
||||
}
|
||||
|
||||
if (preg_match('/^[a-z]{2,3}/', $locale, $matches) === 1) {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
return 'en';
|
||||
}
|
||||
}
|
||||
|
||||
78
tests/Http/ErrorHandlerTest.php
Normal file
78
tests/Http/ErrorHandlerTest.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Http;
|
||||
|
||||
use MintyPHP\Debugger;
|
||||
use MintyPHP\Http\ErrorHandler;
|
||||
use MintyPHP\Http\RequestContext;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ErrorHandlerTest extends TestCase
|
||||
{
|
||||
private array $serverBackup = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->serverBackup = $_SERVER;
|
||||
RequestContext::resetForTests();
|
||||
Debugger::$enabled = false;
|
||||
header_remove();
|
||||
http_response_code(200);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$_SERVER = $this->serverBackup;
|
||||
RequestContext::resetForTests();
|
||||
Debugger::$enabled = false;
|
||||
header_remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* @runInSeparateProcess
|
||||
*/
|
||||
public function testHandleExceptionReturnsJsonForApiRequestsWithRequestId(): void
|
||||
{
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
$_SERVER['REQUEST_URI'] = '/api/v1/ping';
|
||||
$_SERVER['HTTP_X_REQUEST_ID'] = '123e4567-e89b-12d3-a456-426614174000';
|
||||
|
||||
$this->expectOutputRegex('/^(?:(?!<html).)*"error_code":"internal_error".*"request_id":"123e4567-e89b-12d3-a456-426614174000".*$/s');
|
||||
|
||||
ErrorHandler::handleException(new \RuntimeException('API failure'));
|
||||
|
||||
$this->assertSame(500, http_response_code());
|
||||
}
|
||||
|
||||
/**
|
||||
* @runInSeparateProcess
|
||||
*/
|
||||
public function testHandleExceptionRendersProdPageInWebWhenDebugDisabled(): void
|
||||
{
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
$_SERVER['REQUEST_URI'] = '/admin/users';
|
||||
$_SERVER['HTTP_X_REQUEST_ID'] = '123e4567-e89b-12d3-a456-426614174111';
|
||||
|
||||
$this->expectOutputRegex('/^(?=.*<html lang="en">)(?=.*Back to start)(?=.*123e4567-e89b-12d3-a456-426614174111)(?!.*panel-exception).*$/s');
|
||||
|
||||
ErrorHandler::handleException(new \RuntimeException('Web failure'));
|
||||
|
||||
$this->assertSame(500, http_response_code());
|
||||
}
|
||||
|
||||
/**
|
||||
* @runInSeparateProcess
|
||||
*/
|
||||
public function testHandleExceptionKeepsExistingErrorStatusCode(): void
|
||||
{
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
$_SERVER['REQUEST_URI'] = '/admin/settings';
|
||||
http_response_code(500);
|
||||
|
||||
$this->expectOutputRegex('/500/');
|
||||
|
||||
ErrorHandler::handleException(new \RuntimeException('Status flow'));
|
||||
|
||||
$this->assertSame(500, http_response_code());
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ class ErrorLoggerTest extends TestCase
|
||||
'request_id' => 'abc-123',
|
||||
'channel' => 'web',
|
||||
'method' => 'GET',
|
||||
'url' => '/admin/dashboard',
|
||||
'url' => '/admin/dashboard?token=very-secret',
|
||||
'ip' => '127.0.0.1',
|
||||
'user_id' => 5,
|
||||
'tenant_id' => 1,
|
||||
@@ -60,11 +60,41 @@ class ErrorLoggerTest extends TestCase
|
||||
$this->assertSame('error', $entry['level']);
|
||||
$this->assertSame('abc-123', $entry['request_id']);
|
||||
$this->assertSame('RuntimeException', $entry['exception']['class']);
|
||||
$this->assertSame('Test error', $entry['exception']['message']);
|
||||
$this->assertSame('[REDACTED]', $entry['exception']['message']);
|
||||
$this->assertArrayHasKey('message_hash', $entry['exception']);
|
||||
$this->assertStringStartsWith('sha256:', (string) $entry['exception']['message_hash']);
|
||||
$this->assertArrayHasKey('ip_hash', $entry['request']);
|
||||
$this->assertStringStartsWith('sha256:', $entry['request']['ip_hash']);
|
||||
$this->assertSame('/admin/dashboard', $entry['request']['path']);
|
||||
$this->assertArrayNotHasKey('url', $entry['request']);
|
||||
// Raw IP should NOT be in the log.
|
||||
$this->assertStringNotContainsString('127.0.0.1', json_encode($entry));
|
||||
$this->assertStringNotContainsString('very-secret', json_encode($entry));
|
||||
}
|
||||
|
||||
public function testLogStoresPathWithoutQueryParameters(): void
|
||||
{
|
||||
$exception = new \RuntimeException('Query redaction');
|
||||
$data = [
|
||||
'request' => [
|
||||
'request_id' => 'query-test',
|
||||
'url' => '/auth/reset-password?email=user@example.com&token=abc123',
|
||||
],
|
||||
'exception' => [],
|
||||
'environment' => [],
|
||||
];
|
||||
|
||||
ErrorLogger::log($exception, $data);
|
||||
|
||||
$this->assertFileExists($this->logFile);
|
||||
$lines = array_filter(explode("\n", (string) file_get_contents($this->logFile)));
|
||||
$this->assertCount(1, $lines);
|
||||
|
||||
$entry = json_decode($lines[0], true);
|
||||
$this->assertIsArray($entry);
|
||||
$this->assertSame('/auth/reset-password', $entry['request']['path']);
|
||||
$this->assertStringNotContainsString('user@example.com', $lines[0]);
|
||||
$this->assertStringNotContainsString('token=abc123', $lines[0]);
|
||||
}
|
||||
|
||||
public function testLogRedactsIpAddress(): void
|
||||
|
||||
@@ -3,10 +3,28 @@
|
||||
namespace MintyPHP\Tests\Http;
|
||||
|
||||
use MintyPHP\Http\ErrorRenderer;
|
||||
use MintyPHP\I18n;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ErrorRendererTest extends TestCase
|
||||
{
|
||||
private string $localeBackup = '';
|
||||
private string $defaultLocaleBackup = '';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->localeBackup = (string) (I18n::$locale ?? '');
|
||||
$this->defaultLocaleBackup = (string) (I18n::$defaultLocale ?? 'en');
|
||||
I18n::$defaultLocale = 'en';
|
||||
I18n::$locale = 'en';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
I18n::$locale = $this->localeBackup;
|
||||
I18n::$defaultLocale = $this->defaultLocaleBackup;
|
||||
}
|
||||
|
||||
public function testRenderDevReturnsCompleteHtmlDocument(): void
|
||||
{
|
||||
$data = self::sampleData();
|
||||
@@ -16,6 +34,7 @@ class ErrorRendererTest extends TestCase
|
||||
$this->assertStringContainsString('</html>', $html);
|
||||
$this->assertStringContainsString('<style>', $html);
|
||||
$this->assertStringContainsString('<script>', $html);
|
||||
$this->assertStringContainsString('<html lang="en">', $html);
|
||||
}
|
||||
|
||||
public function testRenderDevContainsExceptionDetails(): void
|
||||
@@ -37,6 +56,21 @@ class ErrorRendererTest extends TestCase
|
||||
$this->assertStringContainsString('panel-request', $html);
|
||||
$this->assertStringContainsString('panel-environment', $html);
|
||||
$this->assertStringContainsString('panel-queries', $html);
|
||||
$this->assertStringContainsString('role="tablist"', $html);
|
||||
$this->assertStringContainsString('aria-controls="panel-exception"', $html);
|
||||
$this->assertStringContainsString('role="tabpanel"', $html);
|
||||
}
|
||||
|
||||
public function testRenderDevUsesAccessibleToggleButtons(): void
|
||||
{
|
||||
$data = self::sampleData();
|
||||
$html = ErrorRenderer::renderDev($data);
|
||||
|
||||
$this->assertStringContainsString('app-error-debug-frame__toggle', $html);
|
||||
$this->assertStringContainsString('app-error-debug-query__toggle', $html);
|
||||
$this->assertStringContainsString('aria-expanded="true"', $html);
|
||||
$this->assertStringContainsString('aria-controls="frame-code-0"', $html);
|
||||
$this->assertStringContainsString('aria-label="Copy request ID"', $html);
|
||||
}
|
||||
|
||||
public function testRenderDevEscapesHtmlInExceptionMessage(): void
|
||||
@@ -63,9 +97,12 @@ class ErrorRendererTest extends TestCase
|
||||
$html = ErrorRenderer::renderProd('abc-def-123');
|
||||
|
||||
$this->assertStringContainsString('<!DOCTYPE html>', $html);
|
||||
$this->assertStringContainsString('<html lang="en">', $html);
|
||||
$this->assertStringContainsString('abc-def-123', $html);
|
||||
$this->assertStringContainsString('An unexpected error occurred', $html);
|
||||
$this->assertStringContainsString('Back to start', $html);
|
||||
$this->assertStringContainsString('data-copy="abc-def-123"', $html);
|
||||
$this->assertStringContainsString('aria-label="Copy request ID"', $html);
|
||||
}
|
||||
|
||||
public function testRenderProdDoesNotContainDebugInfo(): void
|
||||
|
||||
@@ -156,6 +156,10 @@ body.app-error-debug {
|
||||
|
||||
.app-error-debug-copy-button:hover { color: var(--app-color); }
|
||||
.app-error-debug-copy-button.copied { color: var(--app-primary); }
|
||||
.app-error-debug-copy-button:focus-visible {
|
||||
outline: 2px solid var(--app-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Exception Summary ───────────────────────────────────────────────── */
|
||||
|
||||
@@ -206,6 +210,10 @@ body.app-error-debug {
|
||||
}
|
||||
|
||||
.app-error-debug-tab:hover { color: var(--app-color); }
|
||||
.app-error-debug-tab:focus-visible {
|
||||
outline: 2px solid var(--app-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.app-error-debug-tab.active {
|
||||
color: var(--app-primary);
|
||||
@@ -251,11 +259,18 @@ body.app-error-debug {
|
||||
cursor: pointer;
|
||||
background: var(--app-card-background-color);
|
||||
transition: background var(--app-transition);
|
||||
width: 100%;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.app-error-debug-frame__header:hover {
|
||||
background: var(--app-card-sectioning-background-color);
|
||||
}
|
||||
.app-error-debug-frame__header:focus-visible {
|
||||
outline: 2px solid var(--app-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.app-error-debug-frame__index {
|
||||
color: var(--app-muted-color);
|
||||
@@ -385,6 +400,19 @@ body.app-error-debug {
|
||||
cursor: pointer;
|
||||
font-family: var(--app-font-family-monospace);
|
||||
font-size: var(--text-xs);
|
||||
width: 100%;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.app-error-debug-query__header:hover {
|
||||
background: var(--app-card-sectioning-background-color);
|
||||
}
|
||||
|
||||
.app-error-debug-query__header:focus-visible {
|
||||
outline: 2px solid var(--app-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.app-error-debug-query__duration {
|
||||
|
||||
@@ -5,32 +5,74 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const tabs = document.querySelectorAll('.app-error-debug-tab');
|
||||
const panels = document.querySelectorAll('.app-error-debug-panel');
|
||||
|
||||
const activateTab = (tab) => {
|
||||
const target = tab.dataset.panel;
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
tabs.forEach(t => t.setAttribute('aria-selected', 'false'));
|
||||
tabs.forEach(t => t.setAttribute('tabindex', '-1'));
|
||||
panels.forEach(p => p.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
tab.setAttribute('aria-selected', 'true');
|
||||
tab.setAttribute('tabindex', '0');
|
||||
const panel = document.getElementById(target);
|
||||
if (panel) panel.classList.add('active');
|
||||
};
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const target = tab.dataset.panel;
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
panels.forEach(p => p.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
const panel = document.getElementById(target);
|
||||
if (panel) panel.classList.add('active');
|
||||
activateTab(tab);
|
||||
});
|
||||
|
||||
tab.addEventListener('keydown', (event) => {
|
||||
if (tabs.length === 0) return;
|
||||
const currentIndex = Array.prototype.indexOf.call(tabs, tab);
|
||||
if (currentIndex < 0) return;
|
||||
|
||||
let nextIndex = currentIndex;
|
||||
if (event.key === 'ArrowRight') {
|
||||
nextIndex = (currentIndex + 1) % tabs.length;
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||
} else if (event.key === 'Home') {
|
||||
nextIndex = 0;
|
||||
} else if (event.key === 'End') {
|
||||
nextIndex = tabs.length - 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const nextTab = tabs[nextIndex];
|
||||
if (!nextTab) return;
|
||||
activateTab(nextTab);
|
||||
nextTab.focus();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Frame expand/collapse ──────────────────────────
|
||||
document.querySelectorAll('.app-error-debug-frame__header').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
header.closest('.app-error-debug-frame')?.classList.toggle('open');
|
||||
document.querySelectorAll('.app-error-debug-frame__toggle').forEach(toggle => {
|
||||
toggle.addEventListener('click', () => {
|
||||
const frame = toggle.closest('.app-error-debug-frame');
|
||||
if (!frame) return;
|
||||
const isOpen = frame.classList.toggle('open');
|
||||
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
});
|
||||
});
|
||||
|
||||
// Open the first frame by default.
|
||||
const firstFrame = document.querySelector('.app-error-debug-frame');
|
||||
if (firstFrame) firstFrame.classList.add('open');
|
||||
// Ensure ARIA reflects initial state.
|
||||
document.querySelectorAll('.app-error-debug-frame').forEach(frame => {
|
||||
const toggle = frame.querySelector('.app-error-debug-frame__toggle');
|
||||
if (!toggle) return;
|
||||
toggle.setAttribute('aria-expanded', frame.classList.contains('open') ? 'true' : 'false');
|
||||
});
|
||||
|
||||
// ── Query expand/collapse ──────────────────────────
|
||||
document.querySelectorAll('.app-error-debug-query__header').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
header.closest('.app-error-debug-query')?.classList.toggle('open');
|
||||
document.querySelectorAll('.app-error-debug-query__toggle').forEach(toggle => {
|
||||
toggle.addEventListener('click', () => {
|
||||
const query = toggle.closest('.app-error-debug-query');
|
||||
if (!query) return;
|
||||
const isOpen = query.classList.toggle('open');
|
||||
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user