diff --git a/composer.json b/composer.json
index a4f2e7530..a772617ef 100644
--- a/composer.json
+++ b/composer.json
@@ -49,7 +49,7 @@
},
"extra": {
"branch-alias": {
- "dev-master": "2.12-dev"
+ "dev-master": "3.0-dev"
}
},
"config": {
diff --git a/src/Bridges/Nette/MailSender.php b/src/Bridges/Nette/MailSender.php
index 996438c15..8f7b01887 100644
--- a/src/Bridges/Nette/MailSender.php
+++ b/src/Bridges/Nette/MailSender.php
@@ -26,7 +26,7 @@ public function __construct(
}
- public function send(mixed $message, string $email): void
+ public function send(mixed $message, string $email, ?string $exceptionFile = null): void
{
$host = preg_replace('#[^\w.-]+#', '', $this->host ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
@@ -41,7 +41,11 @@ public function send(mixed $message, string $email): void
}
$mail->setSubject('PHP: An error occurred on the server ' . $host);
- $mail->setBody(Tracy\Logger::formatMessage($message) . "\n\nsource: " . Tracy\Helpers::getSource());
+ $mail->setBody(
+ Tracy\Logger::formatMessage($message)
+ . "\n\nsource: " . Tracy\Helpers::getSource()
+ . ($exceptionFile ? "\nreport: " . basename($exceptionFile) : ''),
+ );
$this->mailer->send($mail);
}
diff --git a/src/Bridges/Nette/TracyExtension.php b/src/Bridges/Nette/TracyExtension.php
index b9ee02594..e6106ce92 100644
--- a/src/Bridges/Nette/TracyExtension.php
+++ b/src/Bridges/Nette/TracyExtension.php
@@ -156,14 +156,14 @@ public function afterCompile(Nette\PhpGenerator\ClassType $class): void
}
$initialize->addBody($builder->formatPhp('if ($logger instanceof Tracy\Logger) $logger->mailer = ?;', [
- [new Statement(Tracy\Bridges\Nette\MailSender::class, $params), 'send'],
+ [new Statement(Tracy\Bridges\Nette\MailSender::class, $params), 'send'], // TODO: nette/di must be able to create closures
]));
}
if ($this->debugMode) {
foreach ($config->bar as $item) {
if (is_string($item) && str_starts_with($item, '@')) {
- $item = new Statement(['@' . $builder::THIS_CONTAINER, 'getService'], [substr($item, 1)]);
+ $item = new Statement(['@' . $builder::ThisContainer, 'getService'], [substr($item, 1)]);
} elseif (is_string($item)) {
$item = new Statement($item);
}
diff --git a/src/Tracy/Bar/AgentPanel.php b/src/Tracy/Bar/AgentPanel.php
new file mode 100644
index 000000000..ae61ee9fc
--- /dev/null
+++ b/src/Tracy/Bar/AgentPanel.php
@@ -0,0 +1,20 @@
+panels as $panel) {
- if (method_exists($panel, 'getAgentInfo') && ($text = $panel->getAgentInfo()) !== null) {
- $parts[] = $text;
+ foreach ($this->panels as $id => $panel) {
+ try {
+ if (
+ ($panel instanceof AgentPanel || method_exists($panel, 'getAgentInfo'))
+ && ($text = $panel->getAgentInfo()) !== null
+ ) {
+ $parts[] = $text;
+ }
+ } catch (\Throwable $e) { // a broken panel must not kill the whole bar render
+ $parts[] = "Error in panel $id: {$e->getMessage()}";
}
}
diff --git a/src/Tracy/Bar/DefaultBarPanel.php b/src/Tracy/Bar/DefaultBarPanel.php
index e46b990e0..258c456ad 100644
--- a/src/Tracy/Bar/DefaultBarPanel.php
+++ b/src/Tracy/Bar/DefaultBarPanel.php
@@ -13,7 +13,7 @@
* @internal
*/
#[\AllowDynamicProperties]
-class DefaultBarPanel implements IBarPanel
+class DefaultBarPanel implements IBarPanel, AgentPanel
{
public mixed $data = null;
diff --git a/src/Tracy/Bar/IBarPanel.php b/src/Tracy/Bar/IBarPanel.php
index 1e522e809..7d465941c 100644
--- a/src/Tracy/Bar/IBarPanel.php
+++ b/src/Tracy/Bar/IBarPanel.php
@@ -10,7 +10,7 @@
/**
* Tracy Bar panel providing a tab label and optional panel content.
- * @method ?string getAgentInfo() Returns markdown summary for AI agents.
+ * @method ?string getAgentInfo() Returns markdown summary for AI agents, see also the AgentPanel interface.
*/
interface IBarPanel
{
diff --git a/src/Tracy/Bar/assets/bar.css b/src/Tracy/Bar/assets/bar.css
index 5f71d82b8..cee1ff3ff 100644
--- a/src/Tracy/Bar/assets/bar.css
+++ b/src/Tracy/Bar/assets/bar.css
@@ -2,40 +2,54 @@
* This file is part of the Tracy (https://tracy.nette.org)
*/
-/* common styles */
-#tracy-debug {
+@layer tracy-components {
+
+/* shadow DOM host */
+:host {
--tracy-space: 10px;
- display: none;
direction: ltr;
+ display: none;
}
-body#tracy-debug { /* in popup window */
- display: block;
+/* host is shown as a manual popover in the top layer, undo UA popover geometry */
+:host([popover]) {
+ position: static;
+ inset: auto;
+ width: auto;
+ height: auto;
+ margin: 0;
+ border: none;
+ padding: 0;
+ background: none;
+ overflow: visible;
+ color: inherit;
}
-#tracy-debug:not(body) {
- position: absolute;
- left: 0;
- top: 0;
+body#tracy-debug { /* in popup window, the sheet is adopted by the document */
+ --tracy-space: 10px;
+ direction: ltr;
+ display: block;
}
-#tracy-debug a {
+/* common styles */
+a {
color: #125EAE;
text-decoration: none;
}
-#tracy-debug a:hover,
-#tracy-debug a:focus {
+a:hover,
+a:focus {
background-color: #125EAE;
color: white;
}
-#tracy-debug h2,
-#tracy-debug h3 {
+h2,
+h3 {
+ font-size: inherit;
font-weight: bold;
}
-#tracy-debug :where(:is(
+:where(:is(
h1, h2, h3, h4, h5, h6,
p,
ol, ul, dl,
@@ -47,44 +61,44 @@ body#tracy-debug { /* in popup window */
margin-top: var(--tracy-space);
}
-#tracy-debug table {
+table {
background: #FDF5CE;
width: 100%;
}
-#tracy-debug tr:nth-child(2n) td {
+tr:nth-child(2n) td {
background: rgba(0, 0, 0, 0.02);
}
-#tracy-debug td,
-#tracy-debug th {
+td,
+th {
border: 1px solid #E6DFBF;
padding: 2px 5px;
vertical-align: top;
text-align: left;
}
-#tracy-debug th {
+th {
background: #F4F3F1;
color: #655E5E;
font-size: 90%;
font-weight: bold;
}
-#tracy-debug pre,
-#tracy-debug code {
+pre,
+code {
font: 9pt/1.5 Consolas, monospace;
}
-#tracy-debug table .tracy-right {
+table .tracy-right {
text-align: right;
}
-#tracy-debug svg {
+svg {
display: inline;
}
-#tracy-debug .tracy-dump {
+.tracy-dump {
margin: 0;
padding: 2px 5px;
}
@@ -182,7 +196,7 @@ body#tracy-debug { /* in popup window */
/* panels */
-#tracy-debug .tracy-panel {
+.tracy-panel {
display: none;
font: normal normal 12px/1.5 sans-serif;
background: white;
@@ -194,7 +208,7 @@ body#tracy-debug .tracy-panel { /* in popup window */
display: block;
}
-#tracy-debug h1 {
+h1 {
font: normal normal 23px/1.4 Tahoma, sans-serif;
line-height: 1;
color: #575753;
@@ -202,29 +216,29 @@ body#tracy-debug .tracy-panel { /* in popup window */
word-wrap: break-word;
}
-#tracy-debug .tracy-inner {
+.tracy-inner {
overflow: auto;
flex: 1;
}
-#tracy-debug .tracy-panel .tracy-icons {
+.tracy-panel .tracy-icons {
display: none;
}
-#tracy-debug .tracy-panel-ajax h1::after,
-#tracy-debug .tracy-panel-redirect h1::after {
+.tracy-panel-ajax h1::after,
+.tracy-panel-redirect h1::after {
content: 'ajax';
float: right;
font-size: 65%;
margin: 0 .3em;
}
-#tracy-debug .tracy-panel-redirect h1::after {
+.tracy-panel-redirect h1::after {
content: 'redirect';
}
-#tracy-debug .tracy-mode-peek,
-#tracy-debug .tracy-mode-float {
+.tracy-mode-peek,
+.tracy-mode-float {
position: fixed;
flex-direction: column;
padding: var(--tracy-space);
@@ -235,24 +249,24 @@ body#tracy-debug .tracy-panel { /* in popup window */
border: 1px solid rgba(0, 0, 0, 0.1);
}
-#tracy-debug .tracy-mode-peek,
-#tracy-debug .tracy-mode-float:not(.tracy-panel-resized) {
+.tracy-mode-peek,
+.tracy-mode-float:not(.tracy-panel-resized) {
max-width: 700px;
max-height: 500px;
}
@media (max-height: 555px) {
- #tracy-debug .tracy-mode-peek,
- #tracy-debug .tracy-mode-float:not(.tracy-panel-resized) {
+ .tracy-mode-peek,
+ .tracy-mode-float:not(.tracy-panel-resized) {
max-height: 100vh;
}
}
-#tracy-debug .tracy-mode-peek h1 {
+.tracy-mode-peek h1 {
cursor: move;
}
-#tracy-debug .tracy-mode-float {
+.tracy-mode-float {
display: flex;
opacity: .95;
transition: opacity 0.2s;
@@ -261,18 +275,18 @@ body#tracy-debug .tracy-panel { /* in popup window */
resize: both;
}
-#tracy-debug .tracy-focused {
+.tracy-focused {
display: flex;
opacity: 1;
transition: opacity 0.1s;
}
-#tracy-debug .tracy-mode-float h1 {
+.tracy-mode-float h1 {
cursor: move;
padding-right: 25px;
}
-#tracy-debug .tracy-mode-float .tracy-icons {
+.tracy-mode-float .tracy-icons {
display: block;
position: absolute;
top: 0;
@@ -280,25 +294,27 @@ body#tracy-debug .tracy-panel { /* in popup window */
font-size: 18px;
}
-#tracy-debug .tracy-mode-window {
+.tracy-mode-window {
padding: var(--tracy-space);
}
-#tracy-debug .tracy-icons a {
+.tracy-icons a {
color: #575753;
}
-#tracy-debug .tracy-icons a:hover {
+.tracy-icons a:hover {
color: white;
}
-#tracy-debug .tracy-inner-container {
+.tracy-inner-container {
min-width: fit-content;
}
@media print {
- #tracy-debug * {
+ * {
display: none;
}
}
+
+}
diff --git a/src/Tracy/Bar/assets/bar.js b/src/Tracy/Bar/assets/bar.js
index a95eb3387..f7f1dc546 100644
--- a/src/Tracy/Bar/assets/bar.js
+++ b/src/Tracy/Bar/assets/bar.js
@@ -32,7 +32,7 @@ function restoreJSON(key) {
class Panel {
constructor(id) {
this.id = id;
- this.elem = document.getElementById(this.id);
+ this.elem = Debug.shadow.querySelector('#' + CSS.escape(this.id));
this.elem.Tracy = this.elem.Tracy || {};
}
@@ -43,7 +43,7 @@ class Panel {
this.init = function () {};
elem.innerHTML = elem.tracyContent = elem.dataset.tracyContent;
delete elem.dataset.tracyContent;
- Tracy.Dumper.init(Debug.layer);
+ Tracy.Dumper.init(Debug.shadow);
evalScripts(elem);
draggable(elem, {
@@ -166,14 +166,17 @@ class Panel {
let script = doc.createElement('script');
script.src = baseUrl + '_tracy_bar=js&XDEBUG_SESSION_STOP=1';
script.async = true;
- script.addEventListener('load', () => win.Tracy.Dumper.init());
+ script.addEventListener('load', () => {
+ win.Tracy.adoptStyleSheets(doc, ['shared', 'bar']);
+ win.Tracy.Dumper.init();
+ });
doc.head.appendChild(script);
let meta = this.elem.parentElement.lastElementChild;
- doc.body.innerHTML = '
- if ((el = e.target.closest('[data-tracy-snapshot]'))) {
+ if ((el = target.closest('[data-tracy-snapshot]'))) {
let snapshot = JSON.parse(el.getAttribute('data-tracy-snapshot'));
el.removeAttribute('data-tracy-snapshot');
el.querySelectorAll('[data-tracy-dump]').forEach((toggler) => {
@@ -80,7 +82,8 @@ class Dumper {
});
document.documentElement.addEventListener('tracy-toggle', (e) => {
- if (!e.target.matches('.tracy-dump *')) {
+ let target = Tracy.retarget(e);
+ if (!target.matches('.tracy-dump *')) {
return;
}
@@ -93,12 +96,12 @@ class Dumper {
} else if (origE && origE.altKey && cont.querySelector('.tracy-toggle')) { // triggered by alt key
if (e.detail.collapsed) { // reopen
- e.target.classList.toggle('tracy-collapsed', false);
+ target.classList.toggle('tracy-collapsed', false);
cont.classList.toggle('tracy-collapsed', false);
e.detail.collapsed = false;
}
- let expand = e.target.tracyAltExpand = !e.target.tracyAltExpand;
+ let expand = target.tracyAltExpand = !target.tracyAltExpand;
toggleChildren(cont, expand ? {} : false);
}
@@ -106,35 +109,38 @@ class Dumper {
});
document.documentElement.addEventListener('animationend', (e) => {
+ let target = Tracy.retarget(e);
if (e.animationName === 'tracy-dump-flash') {
- e.target.classList.toggle('tracy-dump-flash', false);
+ target.classList.toggle('tracy-dump-flash', false);
}
});
document.addEventListener('mouseover', (e) => {
- if (!e.target.matches('.tracy-dump *')) {
+ let target = Tracy.retarget(e);
+ if (!target.matches('.tracy-dump *')) {
return;
}
let el;
- if (e.target.matches('.tracy-dump-hash') && (el = e.target.closest('tracy-div'))) {
+ if (target.matches('.tracy-dump-hash') && (el = target.getRootNode())) {
el.querySelectorAll('.tracy-dump-hash').forEach((el) => {
- if (el.textContent === e.target.textContent) {
+ if (el.textContent === target.textContent) {
el.classList.add('tracy-dump-highlight');
}
});
return;
}
- if ((el = e.target.closest('.tracy-toggle')) && !el.title) {
+ if ((el = target.closest('.tracy-toggle')) && !el.title) {
el.title = HINT_ALT;
}
});
document.addEventListener('mouseout', (e) => {
- if (e.target.matches('.tracy-dump-hash')) {
- document.querySelectorAll('.tracy-dump-hash.tracy-dump-highlight').forEach((el) => {
+ let target = Tracy.retarget(e);
+ if (target.matches('.tracy-dump-hash')) {
+ target.getRootNode().querySelectorAll('.tracy-dump-hash.tracy-dump-highlight').forEach((el) => {
el.classList.remove('tracy-dump-highlight');
});
}
@@ -389,6 +395,32 @@ function UnknownEntityException() {}
let Tracy = window.Tracy = window.Tracy || {};
Tracy.Dumper = Tracy.Dumper || Dumper;
+if (!customElements.get('tracy-dump')) {
+ customElements.define('tracy-dump', class extends HTMLElement {
+ connectedCallback() {
+ if (this.shadowRoot) {
+ return;
+ }
+ if (document.readyState === 'loading') { // parser-created element may not have its children yet
+ document.addEventListener('DOMContentLoaded', () => this.connectedCallback(), { once: true });
+ return;
+ }
+
+ let shadow = this.attachShadow({ mode: 'open' });
+ Tracy.adoptStyleSheets(shadow, ['shared']);
+
+ // move only non-style/script children into shadow root
+ Array.from(this.childNodes).forEach((child) => {
+ if (child.nodeType !== 1 || (child.tagName !== 'STYLE' && child.tagName !== 'SCRIPT')) {
+ shadow.appendChild(child);
+ }
+ });
+
+ Tracy.Dumper.init(shadow);
+ }
+ });
+}
+
function init() {
Tracy.Dumper.init();
}
diff --git a/src/Tracy/Helpers.php b/src/Tracy/Helpers.php
index 96258dd70..92930fae8 100644
--- a/src/Tracy/Helpers.php
+++ b/src/Tracy/Helpers.php
@@ -55,6 +55,7 @@ public static function editorUri(
string $action = 'open',
string $search = '',
string $replace = '',
+ ?int $column = null,
): ?string
{
if (Debugger::$editor && $file && ($action === 'create' || @is_file($file))) { // @ - may trigger error
@@ -65,7 +66,7 @@ public static function editorUri(
return strtr(Debugger::$editor, [
'%action' => $action,
'%file' => rawurlencode($file),
- '%line' => $line ?: 1,
+ '%line' => ($line ?: 1) . ($column ? ':' . $column : ''),
'%search' => rawurlencode($search),
'%replace' => rawurlencode($replace),
]);
@@ -235,6 +236,22 @@ public static function isHttps(): bool
}
+ /**
+ * Converts a time interval like '2 days' or a number of seconds to seconds.
+ * @internal
+ */
+ public static function parseInterval(string|int $value): int
+ {
+ if (is_numeric($value)) {
+ return (int) $value;
+ } elseif (($time = strtotime((string) $value)) !== false) {
+ return $time - time();
+ }
+
+ throw new \InvalidArgumentException("Invalid time interval '$value'.");
+ }
+
+
/** @internal */
public static function improveException(\Throwable $e): void
{
diff --git a/src/Tracy/Logger/Logger.php b/src/Tracy/Logger/Logger.php
index 39b5d458c..29d42b3c4 100644
--- a/src/Tracy/Logger/Logger.php
+++ b/src/Tracy/Logger/Logger.php
@@ -16,23 +16,28 @@
*/
class Logger implements ILogger
{
- /** @var ?string name of the directory where errors should be logged */
- public $directory;
+ /** name of the directory where errors should be logged */
+ public ?string $directory = null;
- /** @var string|string[]|null email or emails to which send error notifications */
- public $email;
+ /** @var string|string[]|null email or emails to which send error notifications */
+ public string|array|null $email = null;
- /** @var ?string sender of email notifications */
- public $fromEmail;
+ /** sender of email notifications */
+ public ?string $fromEmail = null;
- /** @var string|int interval for sending email is 2 days */
- public $emailSnooze = '2 days';
+ /** interval for sending email is 2 days */
+ public string|int $emailSnooze = '2 days';
- /** @var ?callable(mixed $message, string $email): void handler for sending emails, null disables sending */
- public $mailer;
+ /** @var ?\Closure(mixed $message, string $email, ?string $exceptionFile): void handler for sending emails, null disables sending */
+ public ?\Closure $mailer = null;
- /** @var ?BlueScreen */
- private $blueScreen;
+ /** how long to keep exception report files (.html/.md), e.g. '30 days', null keeps them forever */
+ public ?string $retention = null;
+
+ /** @var array called for every logged message (Slack, Sentry, webhooks...) */
+ public array $notifiers = [];
+
+ private ?BlueScreen $blueScreen = null;
/**
@@ -52,7 +57,7 @@ public function __construct(?string $directory, string|array|null $email = null,
* For levels ERROR, EXCEPTION and CRITICAL it sends email.
* @return ?string logged error filename
*/
- public function log(mixed $message, string $level = self::INFO)
+ public function log(mixed $message, string $level = self::INFO): ?string
{
if (!$this->directory) {
throw new \LogicException('Logging directory is not specified.');
@@ -72,10 +77,16 @@ public function log(mixed $message, string $level = self::INFO)
if ($exceptionFile) {
$this->logException($message, $exceptionFile);
+ $this->appendToIndex($message, $level, $exceptionFile);
+ $this->purgeOldFiles();
+ }
+
+ foreach ($this->notifiers as $notifier) {
+ $notifier($message, $level, $exceptionFile);
}
if (in_array($level, [self::ERROR, self::EXCEPTION, self::CRITICAL], strict: true)) {
- $this->sendEmail($message);
+ $this->sendEmail($message, $exceptionFile);
}
return $exceptionFile;
@@ -83,9 +94,49 @@ public function log(mixed $message, string $level = self::INFO)
/**
- * @param mixed $message
+ * Appends a machine-readable record to exceptions.jsonl, a stable entry point for log aggregators and AI agents.
+ */
+ protected function appendToIndex(\Throwable $exception, string $level, string $exceptionFile): void
+ {
+ $line = json_encode([
+ 'time' => date(\DateTimeInterface::ATOM),
+ 'level' => $level,
+ 'hash' => preg_match('#--(\w+)\.html$#', $exceptionFile, $m) ? $m[1] : null,
+ 'message' => static::formatMessage($exception),
+ 'file' => basename($exceptionFile),
+ 'source' => Helpers::getSource(),
+ ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
+ @file_put_contents($this->directory . '/exceptions.jsonl', $line . PHP_EOL, FILE_APPEND | LOCK_EX); // @ - file may not be writable
+ }
+
+
+ /**
+ * Deletes exception report files older than $retention, at most once a day.
*/
- public static function formatMessage($message): string
+ protected function purgeOldFiles(): void
+ {
+ $marker = $this->directory . '/.tracy-purge';
+ if (
+ !$this->retention
+ || @filemtime($marker) > strtotime('-1 day') // @ - file may not exist
+ ) {
+ return;
+ }
+
+ $oldest = time() - Helpers::parseInterval($this->retention);
+
+ @file_put_contents($marker, ''); // @ - file may not be writable
+ foreach (['html', 'md'] as $ext) {
+ foreach (glob($this->directory . '/*--*--*.' . $ext) ?: [] as $file) {
+ if (@filemtime($file) < $oldest) { // @ - file may be deleted by a concurrent purge
+ @unlink($file);
+ }
+ }
+ }
+ }
+
+
+ public static function formatMessage(mixed $message): string
{
if ($message instanceof \Throwable) {
$tmp = [];
@@ -106,10 +157,7 @@ public static function formatMessage($message): string
}
- /**
- * @param mixed $message
- */
- public static function formatLogLine($message, ?string $exceptionFile = null): string
+ public static function formatLogLine(mixed $message, ?string $exceptionFile = null): string
{
return implode(' ', [
date('[Y-m-d H-i-s]'),
@@ -159,27 +207,16 @@ protected function logException(\Throwable $exception, ?string $file = null): st
}
- /**
- * @param mixed $message
- */
- protected function sendEmail($message): void
+ protected function sendEmail(mixed $message, ?string $exceptionFile = null): void
{
if (!$this->email || !$this->mailer) {
return;
}
- if (is_numeric($this->emailSnooze)) {
- $snooze = (int) $this->emailSnooze;
- } elseif (($time = strtotime($this->emailSnooze)) !== false) {
- $snooze = $time - time();
- } else {
- throw new \InvalidArgumentException("Invalid time interval '$this->emailSnooze'.");
- }
-
$this->throttle(
$this->directory . '/email-sent',
- $snooze,
- fn() => ($this->mailer)($message, implode(', ', (array) $this->email)),
+ Helpers::parseInterval($this->emailSnooze),
+ fn() => ($this->mailer)($message, implode(', ', (array) $this->email), $exceptionFile),
);
}
@@ -214,18 +251,15 @@ private function throttle(string $lockFile, int $interval, \Closure $action): vo
}
- /**
- * Default mailer.
- * @param mixed $message
- * @internal
- */
- public function defaultMailer($message, string $email): void
+ private function defaultMailer(mixed $message, string $email, ?string $exceptionFile = null): void
{
$host = preg_replace('#[^\w.-]+#', '', $_SERVER['SERVER_NAME'] ?? php_uname('n'));
mail(
$email,
"PHP: An error occurred on the server $host",
- static::formatMessage($message) . "\n\nsource: " . Helpers::getSource(),
+ static::formatMessage($message)
+ . "\n\nsource: " . Helpers::getSource()
+ . ($exceptionFile ? "\nreport: " . basename($exceptionFile) : ''),
implode("\r\n", [
'From: ' . ($this->fromEmail ?? "noreply@$host"),
'X-Mailer: Tracy',
diff --git a/src/Tracy/Session/FileSession.php b/src/Tracy/Session/FileSession.php
index 2c13182f4..490e8c72c 100644
--- a/src/Tracy/Session/FileSession.php
+++ b/src/Tracy/Session/FileSession.php
@@ -102,7 +102,10 @@ public function clean(): void
}
- public function __destruct()
+ /**
+ * Writes the data to the file and releases the lock; the file is reopened on next use.
+ */
+ public function flush(): void
{
if (!$this->file) {
return;
@@ -115,4 +118,10 @@ public function __destruct()
fclose($this->file);
$this->file = null;
}
+
+
+ public function __destruct()
+ {
+ $this->flush();
+ }
}
diff --git a/src/Tracy/assets/helpers.js b/src/Tracy/assets/helpers.js
index 83c71b8e7..fdb161f2e 100644
--- a/src/Tracy/assets/helpers.js
+++ b/src/Tracy/assets/helpers.js
@@ -5,7 +5,7 @@ function init() {
document.addEventListener(
'click',
(e) => {
- e.target.closest('a[href^="editor:"]')?.setAttribute('target', '_blank');
+ Tracy.retarget(e).closest('a[href^="editor:"]')?.setAttribute('target', '_blank');
},
true,
);
@@ -14,6 +14,29 @@ function init() {
let Tracy = window.Tracy = window.Tracy || {};
+
+// returns the real event target even when the event crossed a shadow DOM boundary
+Tracy.retarget = (e) => e.composedPath()[0] ?? e.target;
+
+// registry of CSS sources filled by the loader (DeferredContent, Dumper::renderAssets)
+Tracy.css = Tracy.css || {};
+
+// memoized constructed stylesheets, one instance per document
+Tracy.styleSheet = (name) => {
+ let sheets = Tracy.styleSheets = Tracy.styleSheets || {};
+ if (!sheets[name]) {
+ sheets[name] = (new CSSStyleSheet);
+ sheets[name].replaceSync(Tracy.css[name] || '');
+ }
+ return sheets[name];
+};
+
+// adopts named stylesheets into a Document or ShadowRoot, without duplicates
+Tracy.adoptStyleSheets = (root, names) => {
+ let sheets = names.map(Tracy.styleSheet).filter((sheet) => !root.adoptedStyleSheets.includes(sheet));
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, ...sheets];
+};
+
if (!Tracy.helpers) {
init();
Tracy.helpers = true;
diff --git a/src/Tracy/assets/reset.css b/src/Tracy/assets/reset.css
index b0a6a6fc8..36a847af0 100644
--- a/src/Tracy/assets/reset.css
+++ b/src/Tracy/assets/reset.css
@@ -2,385 +2,40 @@
* This file is part of the Tracy (https://tracy.nette.org)
*/
-:root {
+@layer tracy-base, tracy-components;
+
+@layer tracy-base {
+
+:root,
+:host {
--tracy-space: 16px;
}
@media (max-width: 600px) {
- :root {
+ :root,
+ :host {
--tracy-space: 8px;
}
}
-tracy-div:not(a b),
-tracy-div:not(a b) * {
- font: inherit;
- line-height: inherit;
- color: inherit;
- background: transparent;
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
margin: 0;
padding: 0;
- border: none;
- text-align: inherit;
- list-style: inherit;
- opacity: 1;
- border-radius: 0;
- box-shadow: none;
- text-shadow: none;
- box-sizing: border-box;
- text-decoration: none;
- text-transform: inherit;
- white-space: inherit;
- float: none;
- clear: none;
- max-width: initial;
- min-width: initial;
- max-height: initial;
- min-height: initial;
-}
-
-tracy-div:not(a b) *:hover {
- color: inherit;
- background: transparent;
-}
-
-tracy-div:not(a b) *:not(svg):not(img):not(table) {
- width: initial;
- height: initial;
-}
-
-tracy-div:not(a b):before,
-tracy-div:not(a b):after,
-tracy-div:not(a b) *:before,
-tracy-div:not(a b) *:after {
- all: unset;
-}
-
-tracy-div:not(a b) b,
-tracy-div:not(a b) strong {
- font-weight: bold;
-}
-
-tracy-div:not(a b) small {
- font-size: smaller;
-}
-
-tracy-div:not(a b) i,
-tracy-div:not(a b) em {
- font-style: italic;
-}
-
-tracy-div:not(a b) big {
- font-size: larger;
-}
-
-tracy-div:not(a b) small,
-tracy-div:not(a b) sub,
-tracy-div:not(a b) sup {
- font-size: smaller;
-}
-
-tracy-div:not(a b) ins {
- text-decoration: underline;
}
-tracy-div:not(a b) del {
- text-decoration: line-through;
-}
-
-tracy-div:not(a b) table {
+table {
border-collapse: collapse;
}
-tracy-div:not(a b) pre {
- font-family: monospace;
- white-space: pre;
-}
-
-tracy-div:not(a b) code,
-tracy-div:not(a b) kbd,
-tracy-div:not(a b) samp {
- font-family: monospace;
-}
-
-tracy-div:not(a b) input {
- background-color: white;
+input {
padding: 1px;
- border: 1px solid;
}
-tracy-div:not(a b) textarea {
- background-color: white;
- border: 1px solid;
+textarea {
padding: 2px;
- white-space: pre-wrap;
-}
-
-tracy-div:not(a b) select {
- border: 1px solid;
- white-space: pre;
-}
-
-tracy-div:not(a b) article,
-tracy-div:not(a b) aside,
-tracy-div:not(a b) details,
-tracy-div:not(a b) div,
-tracy-div:not(a b) figcaption,
-tracy-div:not(a b) footer,
-tracy-div:not(a b) form,
-tracy-div:not(a b) header,
-tracy-div:not(a b) hgroup,
-tracy-div:not(a b) main,
-tracy-div:not(a b) nav,
-tracy-div:not(a b) section,
-tracy-div:not(a b) summary,
-tracy-div:not(a b) pre,
-tracy-div:not(a b) p,
-tracy-div:not(a b) dl,
-tracy-div:not(a b) dd,
-tracy-div:not(a b) dt,
-tracy-div:not(a b) blockquote,
-tracy-div:not(a b) figure,
-tracy-div:not(a b) address,
-tracy-div:not(a b) h1,
-tracy-div:not(a b) h2,
-tracy-div:not(a b) h3,
-tracy-div:not(a b) h4,
-tracy-div:not(a b) h5,
-tracy-div:not(a b) h6,
-tracy-div:not(a b) ul,
-tracy-div:not(a b) ol,
-tracy-div:not(a b) li,
-tracy-div:not(a b) hr {
- display: block;
-}
-
-tracy-div:not(a b) a,
-tracy-div:not(a b) b,
-tracy-div:not(a b) big,
-tracy-div:not(a b) code,
-tracy-div:not(a b) em,
-tracy-div:not(a b) i,
-tracy-div:not(a b) small,
-tracy-div:not(a b) span,
-tracy-div:not(a b) strong {
- display: inline;
-}
-
-tracy-div:not(a b) table {
- display: table;
-}
-
-tracy-div:not(a b) tr {
- display: table-row;
-}
-
-tracy-div:not(a b) col {
- display: table-column;
-}
-
-tracy-div:not(a b) colgroup {
- display: table-column-group;
-}
-
-tracy-div:not(a b) tbody {
- display: table-row-group;
-}
-
-tracy-div:not(a b) thead {
- display: table-header-group;
-}
-
-tracy-div:not(a b) tfoot {
- display: table-footer-group;
-}
-
-tracy-div:not(a b) td {
- display: table-cell;
-}
-
-tracy-div:not(a b) th {
- display: table-cell;
-}
-
-
-
-/* TableSort */
-tracy-div:not(a b) .tracy-sortable > :first-child > tr:first-child > * {
- position: relative;
-}
-
-tracy-div:not(a b) .tracy-sortable > :first-child > tr:first-child > *:hover:before {
- position: absolute;
- right: .3em;
- content: "\21C5";
- opacity: .4;
- font-weight: normal;
-}
-
-
-/* dump */
-tracy-div:not(a b) .tracy-dump div {
- padding-left: 3ex;
-}
-
-tracy-div:not(a b) .tracy-dump div div {
- border-left: 1px solid rgba(0, 0, 0, .1);
- margin-left: .5ex;
-}
-
-tracy-div:not(a b) .tracy-dump div div:hover {
- border-left-color: rgba(0, 0, 0, .25);
-}
-
-tracy-div:not(a b) .tracy-dump {
- background: #FDF5CE;
- padding: .4em .7em;
- border: 1px dotted silver;
- overflow: auto;
-}
-
-tracy-div:not(a b) table .tracy-dump.tracy-dump { /* overwrite .tracy-dump.tracy-light etc. */
- padding: 0;
- margin: 0;
- border: none;
-}
-
-tracy-div:not(a b) .tracy-dump-location {
- color: gray;
- font-size: 80%;
- text-decoration: none;
- background: none;
- opacity: .5;
- float: right;
- cursor: pointer;
-}
-
-tracy-div:not(a b) .tracy-dump-location:hover,
-tracy-div:not(a b) .tracy-dump-location:focus {
- color: gray;
- background: none;
- opacity: 1;
-}
-
-tracy-div:not(a b) .tracy-dump-array,
-tracy-div:not(a b) .tracy-dump-object {
- color: #C22;
-}
-
-tracy-div:not(a b) .tracy-dump-string {
- color: #35D;
- white-space: break-spaces;
-}
-
-tracy-div:not(a b) div.tracy-dump-string {
- position: relative;
- padding-left: 3.5ex;
-}
-
-tracy-div:not(a b) .tracy-dump-lq {
- margin-left: calc(-1ex - 1px);
-}
-
-tracy-div:not(a b) div.tracy-dump-string:before {
- content: '';
- position: absolute;
- left: calc(3ex - 1px);
- top: 1.5em;
- bottom: 0;
- border-left: 1px solid rgba(0, 0, 0, .1);
-}
-
-tracy-div:not(a b) .tracy-dump-virtual span,
-tracy-div:not(a b) .tracy-dump-dynamic span,
-tracy-div:not(a b) .tracy-dump-string span {
- color: rgba(0, 0, 0, 0.5);
-}
-
-tracy-div:not(a b) .tracy-dump-virtual i,
-tracy-div:not(a b) .tracy-dump-dynamic i,
-tracy-div:not(a b) .tracy-dump-string i {
- font-size: 80%;
- font-style: normal;
- color: rgba(0, 0, 0, 0.5);
- user-select: none;
-}
-
-tracy-div:not(a b) .tracy-dump-number {
- color: #090;
-}
-
-tracy-div:not(a b) .tracy-dump-null,
-tracy-div:not(a b) .tracy-dump-bool {
- color: #850;
-}
-
-tracy-div:not(a b) .tracy-dump-virtual {
- font-style: italic;
-}
-
-tracy-div:not(a b) .tracy-dump-public::after {
- content: ' pub';
-}
-
-tracy-div:not(a b) .tracy-dump-protected::after {
- content: ' pro';
-}
-
-tracy-div:not(a b) .tracy-dump-private::after {
- content: ' pri';
-}
-
-tracy-div:not(a b) .tracy-dump-public::after,
-tracy-div:not(a b) .tracy-dump-protected::after,
-tracy-div:not(a b) .tracy-dump-private::after,
-tracy-div:not(a b) .tracy-dump-hash {
- font-size: 85%;
- color: rgba(0, 0, 0, 0.5);
-}
-
-tracy-div:not(a b) .tracy-dump-indent {
- display: none;
-}
-
-tracy-div:not(a b) .tracy-dump-highlight {
- background: #C22;
- color: white;
- border-radius: 2px;
- padding: 0 2px;
- margin: 0 -2px;
-}
-
-tracy-div:not(a b) span[data-tracy-href] {
- border-bottom: 1px dotted rgba(0, 0, 0, .2);
-}
-
-
-/* toggle */
-tracy-div:not(a b) .tracy-toggle:after {
- content: '';
- display: inline-block;
- vertical-align: middle;
- line-height: 0;
- border-top: .6ex solid;
- border-right: .6ex solid transparent;
- border-left: .6ex solid transparent;
- transform: scale(1, 1.5);
- margin: 0 .2ex 0 .7ex;
- transition: .1s transform;
- opacity: .5;
-}
-
-tracy-div:not(a b) .tracy-toggle.tracy-collapsed:after {
- transform: rotate(-90deg) scale(1, 1.5) translate(.1ex, 0);
-}
-
-
-/* tabs */
-tracy-div:not(a b) .tracy-tab-label {
- user-select: none;
}
-tracy-div:not(a b) .tracy-tab-panel:not(.tracy-active) {
- display: none;
}
diff --git a/src/Tracy/assets/table-sort.css b/src/Tracy/assets/table-sort.css
index 7967a642c..41ac8b70f 100644
--- a/src/Tracy/assets/table-sort.css
+++ b/src/Tracy/assets/table-sort.css
@@ -2,6 +2,8 @@
* This file is part of the Tracy (https://tracy.nette.org)
*/
+@layer tracy-base {
+
.tracy-sortable > :first-child > tr:first-child > * {
position: relative;
}
@@ -13,3 +15,5 @@
opacity: .4;
font-weight: normal;
}
+
+}
diff --git a/src/Tracy/assets/table-sort.js b/src/Tracy/assets/table-sort.js
index 39bae80f6..a222a0f05 100644
--- a/src/Tracy/assets/table-sort.js
+++ b/src/Tracy/assets/table-sort.js
@@ -6,10 +6,11 @@
class TableSort {
static init() {
document.documentElement.addEventListener('click', (e) => {
+ let target = Tracy.retarget(e);
if ((window.getSelection().type !== 'Range')
- && e.target.matches('.tracy-sortable > :first-child > tr:first-child *')
+ && target.matches('.tracy-sortable > :first-child > tr:first-child *')
) {
- TableSort.sort(e.target.closest('td,th'));
+ TableSort.sort(target.closest('td,th'));
}
});
diff --git a/src/Tracy/assets/tabs.css b/src/Tracy/assets/tabs.css
index 9add853a1..adbf44921 100644
--- a/src/Tracy/assets/tabs.css
+++ b/src/Tracy/assets/tabs.css
@@ -2,6 +2,8 @@
* This file is part of the Tracy (https://tracy.nette.org)
*/
+@layer tracy-base {
+
.tracy-tab-label {
user-select: none;
}
@@ -9,3 +11,5 @@
.tracy-tab-panel:not(.tracy-active) {
display: none;
}
+
+}
diff --git a/src/Tracy/assets/tabs.js b/src/Tracy/assets/tabs.js
index 8ec2fe7b2..ced8f2974 100644
--- a/src/Tracy/assets/tabs.js
+++ b/src/Tracy/assets/tabs.js
@@ -6,11 +6,11 @@
class Tabs {
static init() {
document.documentElement.addEventListener('click', (e) => {
- let label, context;
+ let label, context, target = Tracy.retarget(e);
if (
!e.shiftKey && !e.ctrlKey && !e.metaKey
- && (label = e.target.closest('.tracy-tab-label'))
- && (context = e.target.closest('.tracy-tabs'))
+ && (label = target.closest('.tracy-tab-label'))
+ && (context = target.closest('.tracy-tabs'))
) {
Tabs.toggle(context, label);
e.preventDefault();
diff --git a/src/Tracy/assets/toggle.css b/src/Tracy/assets/toggle.css
index acce8e4f8..c715739f3 100644
--- a/src/Tracy/assets/toggle.css
+++ b/src/Tracy/assets/toggle.css
@@ -2,7 +2,9 @@
* This file is part of the Tracy (https://tracy.nette.org)
*/
-.tracy-collapsed {
+@layer tracy-base {
+
+.tracy-collapsed:not([hidden='until-found']) { /* until-found elements are hidden by the UA, keeping them findable via Ctrl+F */
display: none;
}
@@ -33,3 +35,5 @@
.tracy-toggle.tracy-collapsed:after {
transform: rotate(-90deg) scale(1, 1.5) translate(.1ex, 0);
}
+
+}
diff --git a/src/Tracy/assets/toggle.js b/src/Tracy/assets/toggle.js
index bcc82a9f5..12614cedb 100644
--- a/src/Tracy/assets/toggle.js
+++ b/src/Tracy/assets/toggle.js
@@ -16,7 +16,7 @@ class Toggle {
let el;
if (
!e.shiftKey && !e.ctrlKey && !e.metaKey
- && (el = e.target.closest('.tracy-toggle'))
+ && (el = Tracy.retarget(e).closest('.tracy-toggle'))
&& (!start || Math.pow(start[0] - e.clientX, 2) + Math.pow(start[1] - e.clientY, 2) < MOVE_THRESHOLD) // no start = keyboard activation
) {
Toggle.toggle(el, undefined, e);
@@ -24,6 +24,20 @@ class Toggle {
e.stopImmediatePropagation();
}
});
+
+ document.documentElement.addEventListener('keydown', (e) => {
+ let el;
+ if (
+ (e.key === 'Enter' || e.key === ' ')
+ && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey
+ && (el = Tracy.retarget(e).closest('.tracy-toggle'))
+ ) {
+ Toggle.toggle(el, undefined, e);
+ e.preventDefault(); // also suppresses the synthetic click on links
+ e.stopImmediatePropagation();
+ }
+ });
+
Toggle.init = function () {};
}
@@ -31,8 +45,7 @@ class Toggle {
// changes element visibility
static toggle(el, expand, e) {
let collapsed = el.classList.contains('tracy-collapsed'),
- ref = el.getAttribute('data-tracy-ref') || el.getAttribute('href'),
- dest = el;
+ dest = Toggle.findDest(el);
if (typeof expand === 'undefined') {
expand = collapsed;
@@ -40,27 +53,67 @@ class Toggle {
el.dispatchEvent(new CustomEvent('tracy-beforetoggle', {
bubbles: true,
+ composed: true,
detail: { collapsed: !expand, originalEvent: e },
}));
+ el.classList.toggle('tracy-collapsed', !expand);
+ dest.classList.toggle('tracy-collapsed', !expand);
+ if (dest !== el && dest.tracyUntilFound) { // keeps collapsed content reachable by find-in-page
+ if (expand) {
+ dest.removeAttribute('hidden');
+ } else {
+ dest.setAttribute('hidden', 'until-found');
+ }
+ }
+
+ el.dispatchEvent(new CustomEvent('tracy-toggle', {
+ bubbles: true,
+ composed: true,
+ detail: { relatedTarget: dest, collapsed: !expand, originalEvent: e },
+ }));
+ }
+
+
+ // resolves the target element controlled by a toggle
+ static findDest(el) {
+ let ref = el.getAttribute('data-tracy-ref') || el.getAttribute('href'),
+ dest = el;
+
if (!ref || ref === '#') {
ref = '+';
} else if (ref.substr(0, 1) === '#') {
- dest = document;
+ dest = el.getRootNode();
}
ref = ref.match(/(\^\s*([^+\s]*)\s*)?(\+\s*(\S*)\s*)?(.*)/);
dest = ref[1] ? dest.parentNode : dest;
dest = ref[2] ? dest.closest(ref[2]) : dest;
dest = ref[3] ? Toggle.nextElement(dest.nextElementSibling, ref[4]) : dest;
dest = ref[5] ? dest.querySelector(ref[5]) : dest;
+ return dest;
+ }
- el.classList.toggle('tracy-collapsed', !expand);
- dest.classList.toggle('tracy-collapsed', !expand);
- el.dispatchEvent(new CustomEvent('tracy-toggle', {
- bubbles: true,
- detail: { relatedTarget: dest, collapsed: !expand, originalEvent: e },
- }));
+ // makes toggles keyboard-focusable and collapsed content reachable by the
+ // browser's find-in-page (Ctrl+F auto-expands the match)
+ static enhance(root) {
+ root.querySelectorAll('.tracy-toggle:not([tabindex])').forEach((el) => {
+ el.tabIndex = 0;
+ });
+
+ root.querySelectorAll('.tracy-toggle.tracy-collapsed').forEach((el) => {
+ let dest = Toggle.findDest(el);
+ if (dest && dest !== el && !dest.tracyUntilFound && dest.classList.contains('tracy-collapsed')) {
+ Toggle.markUntilFound(dest, el);
+ }
+ });
+ }
+
+
+ static markUntilFound(dest, toggle) {
+ dest.tracyUntilFound = true;
+ dest.setAttribute('hidden', 'until-found');
+ dest.addEventListener('beforematch', () => Toggle.toggle(toggle, true));
}
diff --git a/src/tracy.php b/src/tracy.php
index 81428565a..83c007286 100644
--- a/src/tracy.php
+++ b/src/tracy.php
@@ -7,6 +7,7 @@
*/
require __DIR__ . '/Tracy/Bar/IBarPanel.php';
+require __DIR__ . '/Tracy/Bar/AgentPanel.php';
require __DIR__ . '/Tracy/Bar/Bar.php';
require __DIR__ . '/Tracy/Bar/DefaultBarPanel.php';
require __DIR__ . '/Tracy/BlueScreen/BlueScreen.php';
diff --git a/tests/Dumper/Dumper.dump().html.phpt b/tests/Dumper/Dumper.dump().html.phpt
index 7e6293144..571ae312a 100644
--- a/tests/Dumper/Dumper.dump().html.phpt
+++ b/tests/Dumper/Dumper.dump().html.phpt
@@ -21,7 +21,7 @@ test('html mode', function () {
Assert::same(123, Dumper::dump(123));
Assert::match(
<<<'XX'
-
+
Dumper::dump(123)) 📍
+
dump(123) 📍123
-
+
XX,
ob_get_clean(),
);
@@ -39,10 +39,10 @@ test('dark theme', function () {
dump(123);
Assert::match(
<<<'XX'
- dump(123) 📍123
-
+
XX,
ob_get_clean(),
);
diff --git a/tests/Tracy/Bar.renderAgent().phpt b/tests/Tracy/Bar.renderAgent().phpt
index 9a434206a..75195a0be 100644
--- a/tests/Tracy/Bar.renderAgent().phpt
+++ b/tests/Tracy/Bar.renderAgent().phpt
@@ -15,7 +15,7 @@ test('info line with time and memory', function () {
Debugger::$time = microtime(true) - 0.05; // simulate 50 ms
$bar = Debugger::getBar();
Assert::match(
- "Tracy Bar | %a% ms | %a% MB\n",
+ "Tracy Bar | %a% ms | %a% MB\n\n## System Info\n%A%",
$bar->renderAgent(),
);
});
@@ -96,3 +96,69 @@ test('no dumps section when empty', function () {
$bar->renderAgent(),
);
});
+
+
+class AgentAwarePanel implements Tracy\IBarPanel, Tracy\AgentPanel
+{
+ public function getTab(): string
+ {
+ return 'tab';
+ }
+
+
+ public function getPanel(): string
+ {
+ return 'panel';
+ }
+
+
+ public function getAgentInfo(): ?string
+ {
+ return "## Queries\n\n- SELECT 1";
+ }
+}
+
+class BrokenAgentPanel implements Tracy\IBarPanel, Tracy\AgentPanel
+{
+ public function getTab(): string
+ {
+ return 'tab';
+ }
+
+
+ public function getPanel(): string
+ {
+ return 'panel';
+ }
+
+
+ public function getAgentInfo(): ?string
+ {
+ throw new RuntimeException('boom');
+ }
+}
+
+
+test('custom AgentPanel contributes, a broken panel does not kill the render', function () {
+ $bar = new Tracy\Bar;
+ $bar->addPanel(new AgentAwarePanel, 'agent');
+ $bar->addPanel(new BrokenAgentPanel, 'broken');
+
+ Assert::match(
+ "Tracy Bar | %a% ms | %a% MB\n\n## Queries\n\n- SELECT 1\n\nError in panel broken: boom\n",
+ $bar->renderAgent(),
+ );
+});
+
+
+test('info panel provides system info for agents', function () {
+ $bar = new Tracy\Bar;
+ $panel = new Tracy\DefaultBarPanel('info');
+ $panel->time = 0.05;
+ $bar->addPanel($panel, 'Tracy:info');
+
+ Assert::match(
+ "Tracy Bar | %a% ms | %a% MB\n\n## System Info\n\n- Execution time: %a%\n%A%- PHP: %a%\n%A%",
+ $bar->renderAgent(),
+ );
+});
diff --git a/tests/Tracy/BlueScreen.renderAgent().phpt b/tests/Tracy/BlueScreen.renderAgent().phpt
index 03ee631cb..c1ac803f2 100644
--- a/tests/Tracy/BlueScreen.renderAgent().phpt
+++ b/tests/Tracy/BlueScreen.renderAgent().phpt
@@ -47,6 +47,35 @@ test('caused by section for chained exceptions', function () use ($blueScreen) {
});
+test('custom panel text key is included in agent output', function () {
+ $blueScreen = new Tracy\BlueScreen;
+ $blueScreen->addPanel(fn(?Throwable $e) => $e ? [
+ 'tab' => 'Database',
+ 'panel' => 'SELECT 1
',
+ 'text' => "SELECT 1\nSELECT 2",
+ ] : null);
+ $blueScreen->addPanel(fn(?Throwable $e) => $e ? [
+ 'tab' => 'HTML only',
+ 'panel' => 'no text key',
+ ] : null);
+
+ $output = $blueScreen->renderAgent(new Exception('Oops'));
+ Assert::match("%A%## Database\n\nSELECT 1\nSELECT 2%A%", $output);
+ Assert::notContains('## HTML' . ' only', $output); // panel without 'text' key gets no section (needle split to avoid matching this source line in the stack trace)
+});
+
+
+test('throwing panel does not break agent output', function () {
+ $blueScreen = new Tracy\BlueScreen;
+ $blueScreen->addPanel(function (?Throwable $e): array {
+ throw new LogicException('broken panel');
+ });
+
+ $output = $blueScreen->renderAgent(new Exception('Oops'));
+ Assert::match("%A%# Exception: Oops%A%", $output);
+});
+
+
test('environment section hidden when disabled', function () {
$bs = new Tracy\BlueScreen;
$bs->showEnvironment = false;
diff --git a/tests/Tracy/Debugger.dump().agent.phpt b/tests/Tracy/Debugger.dump().agent.phpt
index 07d782999..c2da2eb30 100644
--- a/tests/Tracy/Debugger.dump().agent.phpt
+++ b/tests/Tracy/Debugger.dump().agent.phpt
@@ -24,7 +24,7 @@ test('agent gets console.log output', function () {
$output = ob_get_clean();
Assert::match(
- '%A% ',
+ '%A% ',
$output,
);
});
diff --git a/tests/Tracy/Debugger.reset().phpt b/tests/Tracy/Debugger.reset().phpt
new file mode 100644
index 000000000..69f9f3dc1
--- /dev/null
+++ b/tests/Tracy/Debugger.reset().phpt
@@ -0,0 +1,43 @@
+ 0);
+
+ Debugger::timer('job');
+ Debugger::reset();
+ usleep(1000);
+ Assert::same(0.0, Debugger::timer('job')); // starts fresh after reset
+});
+
+
+test('request time is re-stamped', function () {
+ Debugger::$time = 123.0;
+ Debugger::reset();
+ Assert::true(Debugger::$time > 123.0);
+});
+
+
+test('barDump data is cleared', function () {
+ Debugger::$productionMode = false;
+ Debugger::barDump('hello');
+ $panel = Debugger::getBar()->getPanel('Tracy:dumps');
+ Assert::count(1, $panel->data);
+
+ Debugger::reset();
+ Assert::null($panel->data);
+
+ Debugger::barDump('world'); // works again after reset
+ Assert::count(1, $panel->data);
+});
diff --git a/tests/Tracy/Logger.index-retention.phpt b/tests/Tracy/Logger.index-retention.phpt
new file mode 100644
index 000000000..2196ff8b4
--- /dev/null
+++ b/tests/Tracy/Logger.index-retention.phpt
@@ -0,0 +1,85 @@
+log(new RuntimeException('First error'), Logger::EXCEPTION);
+ $logger->log(new LogicException('Second error'), Logger::ERROR);
+
+ $lines = file($dir . '/exceptions.jsonl', FILE_IGNORE_NEW_LINES);
+ Assert::count(2, $lines);
+
+ $record = json_decode($lines[0], associative: true);
+ Assert::same(Logger::EXCEPTION, $record['level']);
+ Assert::contains('RuntimeException: First error', $record['message']);
+ Assert::match('%h%', $record['hash']);
+ Assert::match('exception--%a%--' . $record['hash'] . '.html', $record['file']);
+ Assert::true(is_file($dir . '/' . $record['file']));
+
+ $record = json_decode($lines[1], associative: true);
+ Assert::same(Logger::ERROR, $record['level']);
+ Assert::contains('LogicException: Second error', $record['message']);
+});
+
+
+test('old exception reports are purged by retention', function () {
+ $dir = getTempDir() . '/retention';
+ mkdir($dir);
+ $logger = new Logger($dir);
+ $logger->retention = '30 days';
+
+ $old = $dir . '/exception--2020-01-01--12-00--abcdef1234.html';
+ file_put_contents($old, 'old');
+ touch($old, strtotime('-90 days'));
+ file_put_contents(substr($old, 0, -5) . '.md', 'old');
+ touch(substr($old, 0, -5) . '.md', strtotime('-90 days'));
+
+ $logger->log(new RuntimeException('Fresh error'), Logger::EXCEPTION);
+
+ Assert::false(is_file($old));
+ Assert::false(is_file(substr($old, 0, -5) . '.md'));
+ Assert::count(1, glob($dir . '/exception--*.html')); // the fresh report is kept
+});
+
+
+test('purge runs at most once a day', function () {
+ $dir = getTempDir() . '/purge-marker';
+ mkdir($dir);
+ $logger = new Logger($dir);
+ $logger->retention = '30 days';
+
+ file_put_contents($dir . '/.tracy-purge', ''); // fresh marker
+
+ $old = $dir . '/exception--2020-01-01--12-00--abcdef1234.html';
+ file_put_contents($old, 'old');
+ touch($old, strtotime('-90 days'));
+
+ $logger->log(new RuntimeException('Another error'), Logger::EXCEPTION);
+ Assert::true(is_file($old)); // purge skipped, marker is fresh
+});
+
+
+test('invalid retention is reported', function () {
+ $dir = getTempDir() . '/invalid-retention';
+ mkdir($dir);
+ $logger = new Logger($dir);
+ $logger->retention = 'nonsense';
+
+ Assert::exception(
+ fn() => $logger->log(new RuntimeException('Boom'), Logger::EXCEPTION),
+ InvalidArgumentException::class,
+ "Invalid time interval 'nonsense'.",
+ );
+});
diff --git a/tests/Tracy/expected/Debugger.error-in-eval.expect b/tests/Tracy/expected/Debugger.error-in-eval.expect
index 11fd0b6a4..038e63f02 100644
--- a/tests/Tracy/expected/Debugger.error-in-eval.expect
+++ b/tests/Tracy/expected/Debugger.error-in-eval.expect
@@ -16,8 +16,8 @@
-
-
+
+
@@ -32,6 +32,7 @@
search►
+ copy as markdown►
@@ -251,7 +252,8 @@
-
+
+
diff --git a/tests/Tracy/expected/Debugger.exception.fiber.html.expect b/tests/Tracy/expected/Debugger.exception.fiber.html.expect
index e32104186..d05f9f8fd 100644
--- a/tests/Tracy/expected/Debugger.exception.fiber.html.expect
+++ b/tests/Tracy/expected/Debugger.exception.fiber.html.expect
@@ -16,8 +16,8 @@
-
-
+
+
@@ -32,6 +32,7 @@
search►
+ copy as markdown►
diff --git a/tests/Tracy/expected/Debugger.exception.generator.html.expect b/tests/Tracy/expected/Debugger.exception.generator.html.expect
index bba16447c..5bbd4385e 100644
--- a/tests/Tracy/expected/Debugger.exception.generator.html.expect
+++ b/tests/Tracy/expected/Debugger.exception.generator.html.expect
@@ -16,8 +16,8 @@
-
-
+
+
@@ -32,6 +32,7 @@
search►
+ copy as markdown►
diff --git a/tests/Tracy/expected/Debugger.exception.html.expect b/tests/Tracy/expected/Debugger.exception.html.expect
index 493667edd..174113c80 100644
--- a/tests/Tracy/expected/Debugger.exception.html.expect
+++ b/tests/Tracy/expected/Debugger.exception.html.expect
@@ -16,8 +16,8 @@
-
-
+
+
@@ -32,6 +32,7 @@
search►
+ copy as markdown►
@@ -321,7 +322,8 @@
-
+
+
diff --git a/tests/Tracy/expected/Debugger.exception.in-generator.html.expect b/tests/Tracy/expected/Debugger.exception.in-generator.html.expect
index ddaebf9e7..1f7fcd9ad 100644
--- a/tests/Tracy/expected/Debugger.exception.in-generator.html.expect
+++ b/tests/Tracy/expected/Debugger.exception.in-generator.html.expect
@@ -16,8 +16,8 @@
-
-
+
+
@@ -32,6 +32,7 @@
search►
+ copy as markdown►
diff --git a/tests/Tracy/expected/Debugger.strict.html.expect b/tests/Tracy/expected/Debugger.strict.html.expect
index 6198e27df..210b1dee0 100644
--- a/tests/Tracy/expected/Debugger.strict.html.expect
+++ b/tests/Tracy/expected/Debugger.strict.html.expect
@@ -16,8 +16,8 @@
-
-
+
+
@@ -32,6 +32,7 @@
search►
+ copy as markdown►
@@ -260,7 +261,8 @@
-
+
+