diff --git a/.gitattributes b/.gitattributes index 9cdfca08c..a4a7cabc6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,13 +2,14 @@ .github/ export-ignore .gitignore export-ignore AGENTS.md export-ignore -ncs.* export-ignore +dresscode.neon export-ignore phpstan*.neon export-ignore src/**/*.latte export-ignore docs/ export-ignore tests/ export-ignore tools/latte-convert/ export-ignore +* text=auto *.php* diff=php *.sh text eol=lf tools/latte-convert/tests/fixtures/*.latte text eol=lf diff --git a/.github/workflows/coding-style.yml b/.github/workflows/coding-style.yml index 4ab0aee40..78db7e2fc 100644 --- a/.github/workflows/coding-style.yml +++ b/.github/workflows/coding-style.yml @@ -17,18 +17,18 @@ jobs: - run: php temp/code-checker/code-checker --strict-types --no-progress -i tests/Tracy/fixtures -i examples/assets -i *.latte - nette_cs: - name: Nette Coding Standard + dresscode: + name: DressCode runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: shivammathur/setup-php@v2 with: - php-version: 8.3 + php-version: 8.4 coverage: none - - run: composer create-project nette/coding-standard temp/coding-standard ^3 --no-progress - - run: php temp/coding-standard/ecs check src tests tools + - run: composer create-project dresscode/dresscode temp/dresscode ^1.0 --no-progress + - run: php temp/dresscode/bin/dresscode check js: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bb13517de..4700272c2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - php: ['8.2', '8.3', '8.4', '8.5'] + php: ['8.2', '8.3', '8.4', '8.5', '8.6'] sapi: ['php', 'php-cgi'] fail-fast: false @@ -43,10 +43,10 @@ jobs: with: php-version: 8.2 extensions: ds - coverage: none + coverage: pcov - run: composer install --no-progress --prefer-dist - - run: composer tester -- -p phpdbg ${{ env.php-options }} --coverage ./coverage.xml --coverage-src ./src + - run: composer tester -- ${{ env.php-options }} --coverage ./coverage.xml --coverage-src ./src continue-on-error: true - uses: coverallsapp/github-action@v2 with: diff --git a/AGENTS.md b/AGENTS.md index dd94fbd15..5f60c6377 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,12 @@ deferred content, the dumper, BlueScreen, the Bar, the logger). Read the relevan `docs/internals/` seam before editing one - especially `deferred-content.md`, the most counterintuitive part. +`docs/upgrading.md` records, per released version, every change visible to +users of the package (renames, removals, deprecations, signatures, behavior, +configuration), each with its commit hash; internal changes are not in it. It +is the place to look up the history of a public symbol. Do not write to it; it +is extended at release time from the git history. + ## Project Overview Tracy is a debugging and error-visualization library for PHP: BlueScreen error @@ -21,7 +27,7 @@ Dumper, and a production error Logger. It auto-detects development vs production emits **markdown for agents** (automated browsers via `navigator.webdriver`, or any client sending an `X-Tracy-Agent` header) instead of HTML. -- **PHP Version**: 8.2 - 8.5 +- **PHP Version**: 8.2 - 8.6 - **Package**: `tracy/tracy` (currently v2.12) ## Essential Commands @@ -45,8 +51,8 @@ composer compile-templates ## Conventions - Every PHP file starts with `declare(strict_types=1);`; **tabs**; return type and - opening brace on separate lines; Nette Coding Standard (`ncs.php`). JS is linted - with `@nette/eslint-plugin`. + opening brace on separate lines; the Nette coding standard, which + `dresscode.neon` applies. JS is linted with `@nette/eslint-plugin`. - Tests are Nette Tester `.phpt` using `test()` and `getTempDir()`. **CI runs both `php` and `php-cgi`**; UI-rendering tests need `php-cgi`. - Templates are `.latte` (HTML-escaping) / `*.agent.latte` (text/markdown, no diff --git a/composer.json b/composer.json index a4f2e7530..9d0ba4f58 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ } ], "require": { - "php": "8.2 - 8.5", + "php": "8.2 - 8.6", "ext-session": "*", "ext-json": "*" }, @@ -27,7 +27,7 @@ "nette/tester": "^2.6", "latte/latte": "^2.5 || ^3.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "phpstan/phpstan": "^2.1@stable", + "phpstan/phpstan": "^2.3@stable", "phpstan/extension-installer": "^1.4@stable", "nette/phpstan-rules": "^1.0" }, @@ -49,7 +49,7 @@ }, "extra": { "branch-alias": { - "dev-master": "2.12-dev" + "dev-master": "3.0-dev" } }, "config": { diff --git a/docs/internals/bluescreen.md b/docs/internals/bluescreen.md index 26f758e2c..e540c474b 100644 --- a/docs/internals/bluescreen.md +++ b/docs/internals/bluescreen.md @@ -47,7 +47,10 @@ template by comparing against that index. `CodeHighlighter` tokenizes PHP, shows `DisplayLines` (15) lines around the error — the public `$lines` parameters of the `highlight*` methods are ignored — highlights the line and column, and replaces `/*sensitive{*/…/*}*/` regions with `*****` (`Describer::HiddenValue`) before -highlighting (PHP path only, not the plain-text one). +highlighting (not for the `php: false` path). The markdown variant does +not highlight, but its source snippets go through the same +`CodeHighlighter::hideSensitive()`; any new place printing PHP source must too. +The masking keeps the newlines of a multi-line region, so line numbers stay true. Two dumpers exist: `getDumper()` renders **HTML** (`BlueScreen::$maxDepth` etc., `LOCATION_CLASS`, the shared `SNAPSHOT`, scrubber, `keysToHide`) and feeds the page diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 000000000..21a212d08 --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,128 @@ +# Upgrading tracy/tracy + +## 2.12.1 + +- **Changed** `Tracy\Debugger::dump()` called with `return: true` now applies `Tracy\Debugger::$keysToHide` and, in CLI, also `$maxDepth`, `$maxLength` and `$maxItems`; previously it ignored them (69f595b0) +- **Changed** `Tracy\Debugger::enable()` called again after output has already started is now a silent no-op; previously it threw `LogicException` (1ddaa887) +- **Changed** `Tracy\Dumper` now checks properties coming from `__debugInfo()` or from a custom object exporter against `Tracy\Debugger::$keysToHide` and the scrubber; previously such properties were always shown unmasked (e726c8f0) +- **Changed** `Tracy\Logger::$emailSnooze` now throws `InvalidArgumentException` when its value cannot be parsed by `strtotime()`; previously throttling was silently disabled and an email was sent for every error (ed5e3a48) +- **Changed** `Tracy\Bridges\Psr\TracyToPsrLoggerAdapter::log()` now logs an exception passed in the PSR-3 context only once when the message is empty or equal to the exception message; previously the message was logged a second time (6ccd6e55) + +## 2.12.0 + +- **Deprecated** `Tracy\BlueScreen::$collapsePaths`: use `Tracy\Debugger::$transparentPaths`, a static property filled when the class is loaded; the old name is silently deprecated and its value is still merged in (0deea90e) +- **Changed** `Tracy\BlueScreen::renderToFile()` now writes a Markdown companion file with the same basename next to the HTML file (1dbb02d7) +- **Changed** `Tracy\Logger::getExceptionFile()` now reuses an existing log file only when its name ends with the exception hash and `.html`; previously the hash could appear anywhere in the name (1dbb02d7) + +## 2.11.1 + +- **Signature** `Tracy\Bridges\Psr\PsrToTracyLoggerAdapter::log()` now declares the return type `void`; affects subclasses overriding it (2f4c7df4) +- **Config** `tracy.keysToHide` now fills `Tracy\Debugger::$keysToHide` as well, not only `Tracy\BlueScreen::$keysToHide`, so the listed keys are masked in all dumps (ebd29547) + +## 2.11.0 + +- **Renamed** the Tracy Bar panel id `Tracy:errors` → `Tracy:warnings`; the old id was removed (0dda9390) + +## 2.10.5 + +- **Renamed** `Tracy\Debugger::VERSION` → `Tracy\Debugger::Version`; the old name is silently deprecated (3a6fb902) +- **Deprecated** `Tracy\Debugger::PRODUCTION`: use `Tracy\Debugger::Production`; the old name is silently deprecated (alias since 2.9.6) (3a6fb902) +- **Deprecated** `Tracy\Debugger::DEVELOPMENT`: use `Tracy\Debugger::Development`; the old name is silently deprecated (alias since 2.9.6) (3a6fb902) +- **Deprecated** `Tracy\Debugger::DETECT`: use `Tracy\Debugger::Detect`; the old name is silently deprecated (alias since 2.9.6) (3a6fb902) +- **Deprecated** `Tracy\Debugger::COOKIE_SECRET`: use `Tracy\Debugger::CookieSecret`; the old name is silently deprecated (alias since 2.9.6) (3a6fb902) + +## 2.10.0 + +- **Removed** `Tracy\FireLogger`; FireLogger support was dropped without replacement (8eaf4f25) +- **Removed** `Tracy\Debugger::fireLog()`; FireLogger support was dropped without replacement (8eaf4f25) +- **Removed** `Tracy\Debugger::getFireLogger()`; FireLogger support was dropped without replacement (8eaf4f25) +- **Removed** `Tracy\Debugger::$showFireLogger`; FireLogger support was dropped without replacement (8eaf4f25) +- **Signature** `Tracy\ILogger::log()` is now `log(mixed $value, string $level = self::INFO)`; a custom implementation must declare the same types or PHP reports an incompatible signature (9cf27348) +- **Signature** native types were added to parameters, return values and properties; notably `Tracy\Debugger::enable(bool|string|array|null $mode = null, ?string $logDirectory = null, string|array|null $email = null)`, `Tracy\Debugger::$email`, `Tracy\Debugger::$logDirectory`, `Tracy\Debugger::$browser`, `Tracy\Debugger::$errorTemplate`, `Tracy\Debugger::$productionMode`, `Tracy\Debugger::$showLocation`, `Tracy\Debugger::$scream`, `Tracy\Debugger::$logSeverity`, `Tracy\Logger::__construct()` and `Tracy\BlueScreen::addFiber()`. Assigning a value of another type to a now typed static property throws `TypeError` (9cf27348) + +## 2.9.8 + +- **Changed** `Tracy\BlueScreen::highlightLine()` no longer appends the closing `` tag, which `Tracy\BlueScreen::highlightPhp()` adds instead; also in 2.10.5 (ba708e94) + +## 2.9.6 + +- **Renamed** `Tracy\Debugger::PRODUCTION` → `Tracy\Debugger::Production`; the old name still works as an alias (b6cd4a42) +- **Renamed** `Tracy\Debugger::DEVELOPMENT` → `Tracy\Debugger::Development`; the old name still works as an alias (b6cd4a42) +- **Renamed** `Tracy\Debugger::DETECT` → `Tracy\Debugger::Detect`; the old name still works as an alias (b6cd4a42) +- **Renamed** `Tracy\Debugger::COOKIE_SECRET` → `Tracy\Debugger::CookieSecret`; the old name still works as an alias (b6cd4a42) + +## 2.9.2 + +- **Changed** `Tracy\Dumper::dump()` with the option `Tracy\Dumper::HASH => false` now renders a repeated array or object in full; previously it was collapsed to "see above" / "see below" (d9c2e36b) + +## 2.9.1 + +- **Changed** `Tracy\Debugger::$maxDepth` now defaults to 15; previously 7. Set it to 7 explicitly to keep the previous depth (d6d21012) + +## 2.9.0 + +- **Changed** `Tracy\Debugger::$scream` no longer changes `error_reporting()` globally; it now only controls whether `Tracy\Debugger::errorHandler()` processes a silenced error (52a9cc0c) +- **Config** `tracy.logSeverity` accepts as list items only strings naming `E_*` constants; a raw integer or an arbitrary string is rejected, pass a single int bitmask instead of a list (74a9fbee) + +## 2.8.4 + +- **Changed** `Tracy\Dumper::ITEMS` with the value `0` now means no limit; previously it truncated the output to zero items (0bc1b627) +- **Changed** the scrubber callback (`Tracy\Dumper::SCRUBBER`, `Tracy\BlueScreen::$scrubber`) is now called for non-string keys as well, cast to string; previously it was not called for them (c2f56c43) + +## 2.8.2 + +- **Changed** `Tracy\Debugger::$maxDepth` now defaults to 7; previously 3 (845130ec) +- **Changed** `Tracy\BlueScreen::$collapsePaths` now defaults to the `tracy/`, `nette/` and `latte/` directories; previously to the whole `vendor` directory, so stack frames of other packages are no longer collapsed (8c7bdf03) +- **Changed** `Tracy\Debugger::dump()` with `$return = true` now returns plain text in CLI; previously the captured HTML output (34ed4467) + +## 2.8.1 + +- **Changed** `Tracy\BlueScreen::$maxDepth` now defaults to 5; previously 3 (e23800ff) +- **Changed** `Tracy\BlueScreen::$keysToHide` is no longer matched against virtual (computed) properties, so such a property is dumped in full (47cab47b) +- **Changed** `Tracy\BlueScreen::$scrubber` is no longer called for virtual (computed) properties (47cab47b) + +## 2.8.0 + +- **Renamed** `Tracy\Dumper::LOCATION_LINK` → `Tracy\Dumper::LOCATION_SOURCE`; the old name still works as an alias (67e9e791) +- **Signature** `Tracy\BlueScreen::highlightFile()` and `Tracy\BlueScreen::highlightPhp()` lost the `$vars` and `$keysToHide` parameters together with the feature of inlining dumped variables into the source; drop them from calls (7d2862e9) +- **Changed** `Tracy\Dumper::LOCATION_SOURCE` now implies `Tracy\Dumper::LOCATION_CLASS`, whose bit value changed from `0b0100` to `0b0001` (67e9e791) +- **Changed** `Tracy\Debugger::$showLocation` now defaults to `null` and HTML dumps show the source location by default; previously the default was `false` and the location was hidden (2deb1d2d) +- **Changed** `Tracy\Dumper::DEPTH` now defaults to 7; previously 4 (5f275f6c) +- **Changed** `Tracy\Dumper::dump()`, `Tracy\Dumper::toHtml()` and `Tracy\Dumper::toText()` now truncate arrays and objects to 100 items; pass `Tracy\Dumper::ITEMS` to change or lift the limit (1c4dc56c) +- **Changed** `Tracy\Dumper::dump()` now emits inline `
@@ -20,26 +21,26 @@ echo ' 100 ? "\n" : '')
+ . ($json && $this->collapseTop === true ? ' tracy-collapsed' : '') . '"'
+ . ($snapshot !== null ? " data-tracy-snapshot='" . Helpers::jsonEncode($snapshot) . "'" : '')
+ . ($json ? " data-tracy-dump='" . Helpers::jsonEncode($json) . "'" : '')
+ . ($location || strlen($html) > 100 ? "\n" : '')
. '>'
. $location
. $html
@@ -231,7 +231,11 @@ private function renderArray(array|Value $array, int $depth): string
if ($this->lazy !== false) {
$ref = new Value(Value::TypeRef, $array->id);
$this->copySnapshot($ref);
- return '" . $out . '';
+ return '"
+ . $out
+ . '';
} elseif ($this->hash) {
return $out . (isset($this->above[$array->id]) ? ' see above' : ' see below');
@@ -314,7 +318,11 @@ private function renderObject(Value $object, int $depth): string
if ($this->lazy !== false) {
$ref = new Value(Value::TypeRef, $object->id);
$this->copySnapshot($ref);
- return '" . $out . '';
+ return '"
+ . $out
+ . '';
} elseif ($this->hash) {
return $out . (isset($this->above[$object->id]) ? ' see above' : ' see below');
@@ -368,7 +376,11 @@ private function renderResource(Value $resource, int $depth): string
if ($this->lazy !== false) {
$ref = new Value(Value::TypeRef, $resource->id);
$this->copySnapshot($ref);
- return '" . $out . '';
+ return '"
+ . $out
+ . '';
}
return $out . ' see above';
diff --git a/src/Tracy/Dumper/assets/dumper-dark.css b/src/Tracy/Dumper/assets/dumper-dark.css
deleted file mode 100644
index 8346d7216..000000000
--- a/src/Tracy/Dumper/assets/dumper-dark.css
+++ /dev/null
@@ -1,145 +0,0 @@
-/**
- * This file is part of the Tracy (https://tracy.nette.org)
- */
-
-.tracy-dump.tracy-dark {
- text-align: left;
- color: #f8f8f2;
- background: #29292e;
- border-radius: 4px;
- padding: var(--tracy-space);
- margin: var(--tracy-space) 0;
- word-break: break-all;
- white-space: pre-wrap;
-}
-
-.tracy-dump.tracy-dark div {
- padding-left: 2.5ex;
-}
-
-.tracy-dump.tracy-dark div div {
- border-left: 1px solid rgba(255, 255, 255, .1);
- margin-left: .5ex;
-}
-
-.tracy-dump.tracy-dark div div:hover {
- border-left-color: rgba(255, 255, 255, .25);
-}
-
-.tracy-dark .tracy-dump-location {
- color: silver;
- font-size: 80%;
- text-decoration: none;
- background: none;
- opacity: .5;
- float: right;
- cursor: pointer;
-}
-
-.tracy-dark .tracy-dump-location:hover,
-.tracy-dark .tracy-dump-location:focus {
- opacity: 1;
-}
-
-.tracy-dark .tracy-dump-array,
-.tracy-dark .tracy-dump-object {
- color: #f69c2e;
- user-select: text;
-}
-
-.tracy-dark .tracy-dump-string {
- color: #3cdfef;
- white-space: break-spaces;
-}
-
-.tracy-dark div.tracy-dump-string {
- position: relative;
- padding-left: 3.5ex;
-}
-
-.tracy-dark .tracy-dump-lq {
- margin-left: calc(-1ex - 1px);
-}
-
-.tracy-dark div.tracy-dump-string:before {
- content: '';
- position: absolute;
- left: calc(3ex - 1px);
- top: 1.5em;
- bottom: 0;
- border-left: 1px solid rgba(255, 255, 255, .1);
-}
-
-.tracy-dark .tracy-dump-virtual span,
-.tracy-dark .tracy-dump-dynamic span,
-.tracy-dark .tracy-dump-string span {
- color: rgba(255, 255, 255, 0.5);
-}
-
-.tracy-dark .tracy-dump-virtual i,
-.tracy-dark .tracy-dump-dynamic i,
-.tracy-dark .tracy-dump-string i {
- font-size: 80%;
- font-style: normal;
- color: rgba(255, 255, 255, 0.5);
- user-select: none;
-}
-
-.tracy-dark .tracy-dump-number {
- color: #77d285;
-}
-
-.tracy-dark .tracy-dump-null,
-.tracy-dark .tracy-dump-bool {
- color: #f3cb44;
-}
-
-.tracy-dark .tracy-dump-virtual {
- font-style: italic;
-}
-
-.tracy-dark .tracy-dump-public::after {
- content: ' pub';
-}
-
-.tracy-dark .tracy-dump-protected::after {
- content: ' pro';
-}
-
-.tracy-dark .tracy-dump-private::after {
- content: ' pri';
-}
-
-.tracy-dark .tracy-dump-public::after,
-.tracy-dark .tracy-dump-protected::after,
-.tracy-dark .tracy-dump-private::after,
-.tracy-dark .tracy-dump-hash {
- font-size: 85%;
- color: rgba(255, 255, 255, 0.35);
-}
-
-.tracy-dark .tracy-dump-indent {
- display: none;
-}
-
-.tracy-dark .tracy-dump-highlight {
- background: #C22;
- color: white;
- border-radius: 2px;
- padding: 0 2px;
- margin: 0 -2px;
-}
-
-span[data-tracy-href] {
- border-bottom: 1px dotted rgba(255, 255, 255, .2);
-}
-
-.tracy-dark .tracy-dump-flash {
- animation: tracy-dump-flash .2s ease;
-}
-
-@keyframes tracy-dump-flash {
- 0% {
- background: #c0c0c033;
- }
-}
diff --git a/src/Tracy/Dumper/assets/dumper-light.css b/src/Tracy/Dumper/assets/dumper-light.css
deleted file mode 100644
index fb865920d..000000000
--- a/src/Tracy/Dumper/assets/dumper-light.css
+++ /dev/null
@@ -1,145 +0,0 @@
-/**
- * This file is part of the Tracy (https://tracy.nette.org)
- */
-
-.tracy-dump.tracy-light {
- text-align: left;
- color: #444;
- background: #fdf9e2;
- border-radius: 4px;
- padding: var(--tracy-space);
- margin: var(--tracy-space) 0;
- word-break: break-all;
- white-space: pre-wrap;
-}
-
-.tracy-dump.tracy-light div {
- padding-left: 2.5ex;
-}
-
-.tracy-dump.tracy-light div div {
- border-left: 1px solid rgba(0, 0, 0, .1);
- margin-left: .5ex;
-}
-
-.tracy-dump.tracy-light div div:hover {
- border-left-color: rgba(0, 0, 0, .25);
-}
-
-.tracy-light .tracy-dump-location {
- color: gray;
- font-size: 80%;
- text-decoration: none;
- background: none;
- opacity: .5;
- float: right;
- cursor: pointer;
-}
-
-.tracy-light .tracy-dump-location:hover,
-.tracy-light .tracy-dump-location:focus {
- opacity: 1;
-}
-
-.tracy-light .tracy-dump-array,
-.tracy-light .tracy-dump-object {
- color: #C22;
- user-select: text;
-}
-
-.tracy-light .tracy-dump-string {
- color: #35D;
- white-space: break-spaces;
-}
-
-.tracy-light div.tracy-dump-string {
- position: relative;
- padding-left: 3.5ex;
-}
-
-.tracy-light .tracy-dump-lq {
- margin-left: calc(-1ex - 1px);
-}
-
-.tracy-light 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-light .tracy-dump-virtual span,
-.tracy-light .tracy-dump-dynamic span,
-.tracy-light .tracy-dump-string span {
- color: rgba(0, 0, 0, 0.5);
-}
-
-.tracy-light .tracy-dump-virtual i,
-.tracy-light .tracy-dump-dynamic i,
-.tracy-light .tracy-dump-string i {
- font-size: 80%;
- font-style: normal;
- color: rgba(0, 0, 0, 0.5);
- user-select: none;
-}
-
-.tracy-light .tracy-dump-number {
- color: #090;
-}
-
-.tracy-light .tracy-dump-null,
-.tracy-light .tracy-dump-bool {
- color: #850;
-}
-
-.tracy-light .tracy-dump-virtual {
- font-style: italic;
-}
-
-.tracy-light .tracy-dump-public::after {
- content: ' pub';
-}
-
-.tracy-light .tracy-dump-protected::after {
- content: ' pro';
-}
-
-.tracy-light .tracy-dump-private::after {
- content: ' pri';
-}
-
-.tracy-light .tracy-dump-public::after,
-.tracy-light .tracy-dump-protected::after,
-.tracy-light .tracy-dump-private::after,
-.tracy-light .tracy-dump-hash {
- font-size: 85%;
- color: rgba(0, 0, 0, 0.35);
-}
-
-.tracy-light .tracy-dump-indent {
- display: none;
-}
-
-.tracy-light .tracy-dump-highlight {
- background: #C22;
- color: white;
- border-radius: 2px;
- padding: 0 2px;
- margin: 0 -2px;
-}
-
-span[data-tracy-href] {
- border-bottom: 1px dotted rgba(0, 0, 0, .2);
-}
-
-.tracy-light .tracy-dump-flash {
- animation: tracy-dump-flash .2s ease;
-}
-
-@keyframes tracy-dump-flash {
- 0% {
- background: #c0c0c033;
- }
-}
diff --git a/src/Tracy/Dumper/assets/dumper.css b/src/Tracy/Dumper/assets/dumper.css
new file mode 100644
index 000000000..ac2fa7599
--- /dev/null
+++ b/src/Tracy/Dumper/assets/dumper.css
@@ -0,0 +1,196 @@
+/**
+ * This file is part of the Tracy (https://tracy.nette.org)
+ */
+
+@layer tracy-base {
+
+.tracy-dump {
+ --tracy-dump-color: #444;
+ --tracy-dump-background: #fdf9e2;
+ --tracy-dump-guide: rgba(0, 0, 0, .1);
+ --tracy-dump-guide-hover: rgba(0, 0, 0, .25);
+ --tracy-dump-location: gray;
+ --tracy-dump-name: #C22;
+ --tracy-dump-string: #35D;
+ --tracy-dump-number: #090;
+ --tracy-dump-keyword: #850;
+ --tracy-dump-muted: rgba(0, 0, 0, .5);
+ --tracy-dump-hash: rgba(0, 0, 0, .35);
+ --tracy-dump-href-underline: rgba(0, 0, 0, .2);
+}
+
+.tracy-dump.tracy-dark {
+ --tracy-dump-color: #f8f8f2;
+ --tracy-dump-background: #29292e;
+ --tracy-dump-guide: rgba(255, 255, 255, .1);
+ --tracy-dump-guide-hover: rgba(255, 255, 255, .25);
+ --tracy-dump-location: silver;
+ --tracy-dump-name: #f69c2e;
+ --tracy-dump-string: #3cdfef;
+ --tracy-dump-number: #77d285;
+ --tracy-dump-keyword: #f3cb44;
+ --tracy-dump-muted: rgba(255, 255, 255, .5);
+ --tracy-dump-hash: rgba(255, 255, 255, .35);
+ --tracy-dump-href-underline: rgba(255, 255, 255, .2);
+}
+
+@media (prefers-color-scheme: dark) {
+ .tracy-dump.tracy-auto {
+ --tracy-dump-color: #f8f8f2;
+ --tracy-dump-background: #29292e;
+ --tracy-dump-guide: rgba(255, 255, 255, .1);
+ --tracy-dump-guide-hover: rgba(255, 255, 255, .25);
+ --tracy-dump-location: silver;
+ --tracy-dump-name: #f69c2e;
+ --tracy-dump-string: #3cdfef;
+ --tracy-dump-number: #77d285;
+ --tracy-dump-keyword: #f3cb44;
+ --tracy-dump-muted: rgba(255, 255, 255, .5);
+ --tracy-dump-hash: rgba(255, 255, 255, .35);
+ --tracy-dump-href-underline: rgba(255, 255, 255, .2);
+ }
+}
+
+.tracy-dump {
+ text-align: left;
+ color: var(--tracy-dump-color);
+ background: var(--tracy-dump-background);
+ border-radius: 4px;
+ padding: var(--tracy-space);
+ margin: var(--tracy-space) 0;
+ word-break: break-all;
+ white-space: pre-wrap;
+}
+
+.tracy-dump div {
+ padding-left: 2.5ex;
+}
+
+.tracy-dump div div {
+ border-left: 1px solid var(--tracy-dump-guide);
+ margin-left: .5ex;
+}
+
+.tracy-dump div div:hover {
+ border-left-color: var(--tracy-dump-guide-hover);
+}
+
+.tracy-dump .tracy-dump-location {
+ color: var(--tracy-dump-location);
+ font-size: 80%;
+ text-decoration: none;
+ background: none;
+ opacity: .5;
+ float: right;
+ cursor: pointer;
+}
+
+.tracy-dump .tracy-dump-location:hover,
+.tracy-dump .tracy-dump-location:focus {
+ opacity: 1;
+}
+
+.tracy-dump .tracy-dump-array,
+.tracy-dump .tracy-dump-object {
+ color: var(--tracy-dump-name);
+ user-select: text;
+}
+
+.tracy-dump .tracy-dump-string {
+ color: var(--tracy-dump-string);
+ white-space: break-spaces;
+}
+
+.tracy-dump div.tracy-dump-string {
+ position: relative;
+ padding-left: 3.5ex;
+}
+
+.tracy-dump .tracy-dump-lq {
+ margin-left: calc(-1ex - 1px);
+}
+
+.tracy-dump div.tracy-dump-string:before {
+ content: '';
+ position: absolute;
+ left: calc(3ex - 1px);
+ top: 1.5em;
+ bottom: 0;
+ border-left: 1px solid var(--tracy-dump-guide);
+}
+
+.tracy-dump .tracy-dump-virtual span,
+.tracy-dump .tracy-dump-dynamic span,
+.tracy-dump .tracy-dump-string span {
+ color: var(--tracy-dump-muted);
+}
+
+.tracy-dump .tracy-dump-virtual i,
+.tracy-dump .tracy-dump-dynamic i,
+.tracy-dump .tracy-dump-string i {
+ font-size: 80%;
+ font-style: normal;
+ color: var(--tracy-dump-muted);
+ user-select: none;
+}
+
+.tracy-dump .tracy-dump-number {
+ color: var(--tracy-dump-number);
+}
+
+.tracy-dump .tracy-dump-null,
+.tracy-dump .tracy-dump-bool {
+ color: var(--tracy-dump-keyword);
+}
+
+.tracy-dump .tracy-dump-virtual {
+ font-style: italic;
+}
+
+.tracy-dump .tracy-dump-public::after {
+ content: ' pub';
+}
+
+.tracy-dump .tracy-dump-protected::after {
+ content: ' pro';
+}
+
+.tracy-dump .tracy-dump-private::after {
+ content: ' pri';
+}
+
+.tracy-dump .tracy-dump-public::after,
+.tracy-dump .tracy-dump-protected::after,
+.tracy-dump .tracy-dump-private::after,
+.tracy-dump .tracy-dump-hash {
+ font-size: 85%;
+ color: var(--tracy-dump-hash);
+}
+
+.tracy-dump .tracy-dump-indent {
+ display: none;
+}
+
+.tracy-dump .tracy-dump-highlight {
+ background: #C22;
+ color: white;
+ border-radius: 2px;
+ padding: 0 2px;
+ margin: 0 -2px;
+}
+
+.tracy-dump span[data-tracy-href] {
+ border-bottom: 1px dotted var(--tracy-dump-href-underline);
+}
+
+.tracy-dump .tracy-dump-flash {
+ animation: tracy-dump-flash .2s ease;
+}
+
+@keyframes tracy-dump-flash {
+ 0% {
+ background: #c0c0c033;
+ }
+}
+
+}
diff --git a/src/Tracy/Dumper/assets/dumper.js b/src/Tracy/Dumper/assets/dumper.js
index 29bd5a80b..e6e792f3c 100644
--- a/src/Tracy/Dumper/assets/dumper.js
+++ b/src/Tracy/Dumper/assets/dumper.js
@@ -48,15 +48,17 @@ class Dumper {
// must be left for debug bar panel content
});
+ Tracy.Toggle.enhance(context || document);
+
if (Dumper.inited) {
return;
}
Dumper.inited = true;
document.documentElement.addEventListener('click', (e) => {
- let el;
+ let el, target = Tracy.retarget(e);
// enables & ctrl or cmd key
- if ((e.ctrlKey || e.metaKey) && (el = e.target.closest('[data-tracy-href]'))) {
+ if ((e.ctrlKey || e.metaKey) && (el = target.closest('[data-tracy-href]'))) {
location.href = el.getAttribute('data-tracy-href');
return false;
}
@@ -64,9 +66,9 @@ class Dumper {
});
document.documentElement.addEventListener('tracy-beforetoggle', (e) => {
- let el;
+ let el, target = Tracy.retarget(e);
// initializes lazy inside
- 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..51257a181 100644
--- a/src/Tracy/Helpers.php
+++ b/src/Tracy/Helpers.php
@@ -7,8 +7,8 @@
namespace Tracy;
-use function array_filter, array_map, array_merge, array_pop, array_slice, array_unique, basename, bin2hex, class_exists, constant, count, dechex, defined, dirname, end, escapeshellarg, explode, extension_loaded, func_get_args, function_exists, get_class_methods, get_declared_classes, get_defined_functions, getenv, getmypid, headers_list, htmlspecialchars, htmlspecialchars_decode, iconv_strlen, implode, in_array, ini_set, is_a, is_array, is_callable, is_file, is_object, is_string, json_encode, levenshtein, ltrim, mb_strlen, mb_substr, method_exists, ob_end_clean, ob_get_clean, ob_start, ord, preg_match, preg_replace, preg_replace_callback, random_bytes, rawurlencode, rtrim, sapi_windows_vt100_support, spl_object_id, str_contains, str_pad, str_replace, strcasecmp, stream_isatty, strip_tags, strlen, strtoupper, strtr, substr, trait_exists, utf8_decode;
-use const DIRECTORY_SEPARATOR, ENT_HTML5, ENT_QUOTES, ENT_SUBSTITUTE, JSON_HEX_AMP, JSON_HEX_APOS, JSON_HEX_TAG, JSON_INVALID_UTF8_SUBSTITUTE, JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE, PHP_EOL, PHP_SAPI, STDOUT, STR_PAD_LEFT;
+use function count, defined, extension_loaded, func_get_args, function_exists, is_array, is_object, is_string, strlen;
+use const ENT_HTML5, ENT_QUOTES, ENT_SUBSTITUTE, PHP_SAPI;
/**
@@ -40,6 +40,7 @@ public static function editorLink(string $file, ?int $line = null): string
basename($file),
$line ? ":$line" : '',
);
+
} else {
return self::formatHtml('%', $file . ($line ? ":$line" : ''));
}
@@ -55,6 +56,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 +67,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),
]);
@@ -141,7 +143,10 @@ public static function countTransparentFrames(array $trace, ?array $paths = null
$next = $trace[$key + 1] ?? null;
$nextReflection = match (true) {
$next === null => null,
- isset($next['class'], $next['function']) && method_exists($next['class'], $next['function']) => new \ReflectionMethod($next['class'], $next['function']),
+ isset($next['class'], $next['function']) && method_exists($next['class'], $next['function']) => new \ReflectionMethod(
+ $next['class'],
+ $next['function'],
+ ),
isset($next['function']) && function_exists($next['function']) => new \ReflectionFunction($next['function']),
default => null,
};
@@ -235,6 +240,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
{
@@ -256,15 +277,19 @@ public static function improveException(\Throwable $e): void
} elseif (is_array($arg) && !method_exists($arg[0], $arg[1])) {
$hint = self::getSuggestion(get_class_methods($arg[0]) ?: [], $arg[1]);
$class = is_object($arg[0]) ? $arg[0]::class : $arg[0];
- $message = str_replace($m[2], "but method $class::$arg[1]() does not exist" . ($hint ? " (did you mean $hint?)" : ''), $message);
+ $message = str_replace(
+ $m[2],
+ "but method $class::$arg[1]() does not exist" . ($hint ? " (did you mean $hint?)" : ''),
+ $message,
+ );
} elseif (is_string($arg) && !function_exists($arg)) {
- $funcs = array_merge(get_defined_functions()['internal'], get_defined_functions()['user']);
+ $funcs = [...get_defined_functions()['internal'], ...get_defined_functions()['user']];
$hint = self::getSuggestion($funcs, $arg);
$message = str_replace($m[2], "but function '$arg' does not exist" . ($hint ? " (did you mean $hint?)" : ''), $message);
}
} elseif (preg_match('#^Call to undefined function (\S+\\\)?(\w+)\(#', $message, $m)) {
- $funcs = array_merge(get_defined_functions()['internal'], get_defined_functions()['user']);
+ $funcs = [...get_defined_functions()['internal'], ...get_defined_functions()['user']];
if ($hint = self::getSuggestion($funcs, $m[1] . $m[2]) ?: self::getSuggestion($funcs, $m[2])) {
$message = "Call to undefined function $m[2](), did you mean $hint()?";
$replace = ["$m[2](", "$hint("];
@@ -303,7 +328,7 @@ public static function improveException(\Throwable $e): void
if (isset($replace)) {
$loc = Debugger::mapSource($e->getFile(), $e->getLine()) ?? ['file' => $e->getFile(), 'line' => $e->getLine()];
- @$e->tracyAction = [ // dynamic properties are deprecated since PHP 8.2
+ @$e->tracyAction = [ // @ dynamic properties are deprecated
'link' => self::editorUri($loc['file'], $loc['line'], 'fix', $replace[0], $replace[1]),
'label' => 'fix it',
];
@@ -341,7 +366,7 @@ public static function guessClassFile(string $class): ?string
if ($i > $max && $i < count($segments) && ($file = (new \ReflectionClass($class))->getFileName())) {
$max = $i;
- $res = array_merge(array_slice(explode(DIRECTORY_SEPARATOR, $file), 0, $i - count($parts)), array_slice($segments, $i));
+ $res = [...array_slice(explode(DIRECTORY_SEPARATOR, $file), 0, $i - count($parts)), ...array_slice($segments, $i)];
$res = implode(DIRECTORY_SEPARATOR, $res) . '.php';
}
}
@@ -359,7 +384,10 @@ public static function getSuggestion(array $items, string $value): ?string
{
$best = null;
$min = (strlen($value) / 4 + 1) * 10 + .1;
- $items = array_map(fn($item) => $item instanceof \ReflectionMethod || $item instanceof \ReflectionProperty ? $item->getName() : (string) $item, $items);
+ $items = array_map(
+ fn($item) => $item instanceof \ReflectionMethod || $item instanceof \ReflectionProperty ? $item->getName() : (string) $item,
+ $items,
+ );
foreach (array_unique($items) as $item) {
if (($len = levenshtein($item, $value, 10, 11, 10)) > 0 && $len < $min) {
$min = $len;
@@ -421,7 +449,11 @@ public static function isCli(): bool
/** @internal */
public static function getNonce(bool $attr = false): ?string
{
- return preg_match('#^Content-Security-Policy(?:-Report-Only)?:.*\sscript-src(?:-elem)?\s+(?:[^;]+\s)?\'nonce-([\w+/]+=*)\'#mi', implode("\n", headers_list()), $m)
+ return preg_match(
+ '#^Content-Security-Policy(?:-Report-Only)?:.*\sscript-src(?:-elem)?\s+(?:[^;]+\s)?\'nonce-([\w+/]+=*)\'#mi',
+ implode("\n", headers_list()),
+ $m,
+ )
? ($attr ? ' nonce="' . self::escapeHtml($m[1]) . '"' : $m[1])
: null;
}
@@ -496,8 +528,8 @@ private static function doEncodeString(string $s, bool $utf8, bool $showWhitespa
$s = preg_replace_callback(
$utf8 ? '#[\p{C}<&]#u' : '#[\x00-\x1F\x7F-\xFF<&]#',
fn($m) => $special[$m[0]] ?? (strlen($m[0]) === 1
- ? '\x' . str_pad(strtoupper(dechex(ord($m[0]))), 2, '0', STR_PAD_LEFT) . ''
- : '\u{' . strtoupper(ltrim(dechex(self::utf8Ord($m[0])), '0')) . '}'),
+ ? '\x' . str_pad(strtoupper(dechex(ord($m[0]))), 2, '0', STR_PAD_LEFT) . ''
+ : '\u{' . strtoupper(ltrim(dechex(self::utf8Ord($m[0])), '0')) . '}'),
$s,
);
$s = str_replace('', '', $s);
@@ -527,7 +559,7 @@ public static function utf8Length(string $s): int
return match (true) {
extension_loaded('mbstring') => mb_strlen($s, 'UTF-8'),
extension_loaded('iconv') => iconv_strlen($s, 'UTF-8') ?: strlen($s),
- default => strlen(@utf8_decode($s)), // deprecated
+ default => strlen(@utf8_decode($s)), // @ utf8_decode() is deprecated
};
}
@@ -682,7 +714,7 @@ public static function detectColors(): bool
public static function getExceptionChain(\Throwable $ex): array
{
$res = [$ex];
- while (($ex = $ex->getPrevious()) && !in_array($ex, $res, strict: true)) {
+ while (($ex = $ex->getPrevious()) && !in_array($ex, $res, true)) {
$res[] = $ex;
}
@@ -759,7 +791,15 @@ public static function jsonEncode(mixed $value, bool $inScript = false): string
{
$old = @ini_set('serialize_precision', '-1'); // @ may be disabled
try {
- return json_encode($value, ($inScript ? JSON_HEX_TAG : 0) | JSON_HEX_APOS | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
+ return json_encode(
+ $value,
+ ($inScript ? JSON_HEX_TAG : 0)
+ | JSON_HEX_APOS
+ | JSON_HEX_AMP
+ | JSON_UNESCAPED_UNICODE
+ | JSON_UNESCAPED_SLASHES
+ | JSON_INVALID_UTF8_SUBSTITUTE,
+ );
} finally {
if ($old !== false) {
ini_set('serialize_precision', $old);
diff --git a/src/Tracy/Logger/Logger.php b/src/Tracy/Logger/Logger.php
index 39b5d458c..58ecd60c6 100644
--- a/src/Tracy/Logger/Logger.php
+++ b/src/Tracy/Logger/Logger.php
@@ -7,8 +7,8 @@
namespace Tracy;
-use function in_array, is_string;
-use const DIRECTORY_SEPARATOR, FILE_APPEND, LOCK_EX, LOCK_UN, PHP_EOL;
+use function is_string;
+use const DIRECTORY_SEPARATOR, FILE_APPEND, JSON_INVALID_UTF8_SUBSTITUTE, JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE, LOCK_EX;
/**
@@ -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 */
+ 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);
+ if (in_array($level, [self::ERROR, self::EXCEPTION, self::CRITICAL], true)) {
+ $this->sendEmail($message, $exceptionFile);
}
return $exceptionFile;
@@ -83,16 +94,59 @@ 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); // @ file may have been deleted meanwhile
+ }
+ }
+ }
+ }
+
+
+ public static function formatMessage(mixed $message): string
{
if ($message instanceof \Throwable) {
$tmp = [];
foreach (Helpers::getExceptionChain($message) as $exception) {
$tmp[] = ($exception instanceof \ErrorException
? Helpers::errorTypeToString($exception->getSeverity()) . ': ' . $exception->getMessage()
- : get_debug_type($exception) . ': ' . $exception->getMessage() . ($exception->getCode() ? ' #' . $exception->getCode() : '')
+ : get_debug_type($exception)
+ . ': '
+ . $exception->getMessage()
+ . ($exception->getCode() ? ' #' . $exception->getCode() : '')
) . ' in ' . $exception->getFile() . ':' . $exception->getLine();
}
@@ -106,10 +160,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 +210,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 +254,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/OutputDebugger/OutputDebugger.php b/src/Tracy/OutputDebugger/OutputDebugger.php
index 87b21605e..a4841ac46 100644
--- a/src/Tracy/OutputDebugger/OutputDebugger.php
+++ b/src/Tracy/OutputDebugger/OutputDebugger.php
@@ -7,8 +7,7 @@
namespace Tracy;
-use function array_slice, count;
-use const PHP_OUTPUT_HANDLER_FINAL;
+use function count;
/**
diff --git a/src/Tracy/Session/FileSession.php b/src/Tracy/Session/FileSession.php
index 2c13182f4..a7164be54 100644
--- a/src/Tracy/Session/FileSession.php
+++ b/src/Tracy/Session/FileSession.php
@@ -8,7 +8,6 @@
namespace Tracy;
use function is_string;
-use const LOCK_EX, LOCK_UN;
/**
@@ -53,7 +52,7 @@ private function open(): void
if (
!is_string($id)
|| !preg_match('#^\w{10}\z#i', $id)
- || !($file = @fopen($path = $this->dir . '/' . self::FilePrefix . $id, 'r+')) // intentionally @
+ || !($file = @fopen($path = $this->dir . '/' . self::FilePrefix . $id, 'r+')) // @ file may not exist
) {
$id = Helpers::createId();
setcookie($this->cookieName, $id, [
@@ -64,7 +63,7 @@ private function open(): void
'samesite' => 'Lax',
]);
- $file = @fopen($path = $this->dir . '/' . self::FilePrefix . $id, 'c+'); // intentionally @
+ $file = @fopen($path = $this->dir . '/' . self::FilePrefix . $id, 'c+'); // @ is escalated to exception
if ($file === false) {
throw new \RuntimeException("Unable to create file '$path'. " . (error_get_last()['message'] ?? ''));
}
@@ -72,7 +71,7 @@ private function open(): void
@chmod($path, 0o600); // @ - may not be supported by the filesystem
}
- if (!@flock($file, LOCK_EX)) { // intentionally @
+ if (!@flock($file, LOCK_EX)) { // @ is escalated to exception
throw new \RuntimeException("Unable to acquire exclusive lock on '$path'. " . (error_get_last()['message'] ?? ''));
}
@@ -102,7 +101,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 +117,10 @@ public function __destruct()
fclose($this->file);
$this->file = null;
}
+
+
+ public function __destruct()
+ {
+ $this->flush();
+ }
}
diff --git a/src/Tracy/Session/NativeSession.php b/src/Tracy/Session/NativeSession.php
index b662ed2e8..9020ae2c2 100644
--- a/src/Tracy/Session/NativeSession.php
+++ b/src/Tracy/Session/NativeSession.php
@@ -7,8 +7,6 @@
namespace Tracy;
-use const PHP_SESSION_ACTIVE;
-
/**
* Session storage backed by native PHP session ($_SESSION).
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().cli.phpt b/tests/Dumper/Dumper.dump().cli.phpt
index 102b1c2bd..efa184976 100644
--- a/tests/Dumper/Dumper.dump().cli.phpt
+++ b/tests/Dumper/Dumper.dump().cli.phpt
@@ -5,8 +5,7 @@
*/
use Tester\Assert;
-use Tracy\Debugger;
-use Tracy\Dumper;
+use Tracy\{Debugger, Dumper};
require __DIR__ . '/../bootstrap.php';
diff --git a/tests/Dumper/Dumper.dump().html.phpt b/tests/Dumper/Dumper.dump().html.phpt
index 7e6293144..19013bbb9 100644
--- a/tests/Dumper/Dumper.dump().html.phpt
+++ b/tests/Dumper/Dumper.dump().html.phpt
@@ -5,8 +5,7 @@
*/
use Tester\Assert;
-use Tracy\Debugger;
-use Tracy\Dumper;
+use Tracy\{Debugger, Dumper};
require __DIR__ . '/../bootstrap.php';
@@ -21,7 +20,7 @@ test('html mode', function () {
Assert::same(123, Dumper::dump(123));
Assert::match(
<<<'XX'
-
+
Dumper::dump(123)) 📍NAN' . "\n", Dumper::toHtml(NAN));
-Assert::same('\'\'' . "\n", Dumper::toHtml(''));
+Assert::same(
+ '\'\'' . "\n",
+ Dumper::toHtml(''),
+);
-Assert::same('\'0\'' . "\n", Dumper::toHtml('0'));
+Assert::same(
+ '\'0\'' . "\n",
+ Dumper::toHtml('0'),
+);
-Assert::same('\'\x00\'' . "\n", Dumper::toHtml("\x00"));
+Assert::same(
+ '\'\x00\'' . "\n",
+ Dumper::toHtml("\x00"),
+);
Assert::match('\'a\n' . "\n b'
\n", Dumper::toHtml("a\nb"));
diff --git a/tests/Dumper/Dumper.toHtml().snapshot.phpt b/tests/Dumper/Dumper.toHtml().snapshot.phpt
index c2b6c2809..f2eb975ed 100644
--- a/tests/Dumper/Dumper.toHtml().snapshot.phpt
+++ b/tests/Dumper/Dumper.toHtml().snapshot.phpt
@@ -4,8 +4,7 @@
* Test: Tracy\Dumper::toHtml() snapshop
*/
-use Tester\Assert;
-use Tester\Expect;
+use Tester\{Assert, Expect};
use Tracy\Dumper;
require __DIR__ . '/../bootstrap.php';
diff --git a/tests/Dumper/Dumper.toText().specials.dom.phpt b/tests/Dumper/Dumper.toText().specials.dom.phpt
index 054bcc925..98a0e3a7c 100644
--- a/tests/Dumper/Dumper.toText().specials.dom.phpt
+++ b/tests/Dumper/Dumper.toText().specials.dom.phpt
@@ -11,7 +11,7 @@ require __DIR__ . '/../bootstrap.php';
$dom = new DOMDocument;
-$dom->loadHtml('- Ahoj
');
+$dom->loadHTML('- Ahoj
');
$xpath = new DOMXPath($dom);
$nodeList = $xpath->query('//li');
diff --git a/tests/Dumper/Dumper.toText().specials.enum.phpt b/tests/Dumper/Dumper.toText().specials.enum.phpt
index 6fe4e1ca1..f76d829f6 100644
--- a/tests/Dumper/Dumper.toText().specials.enum.phpt
+++ b/tests/Dumper/Dumper.toText().specials.enum.phpt
@@ -20,17 +20,17 @@ enum Suit
enum Methods: string
{
- case GET = 'get';
- case POST = 'post';
+ case Get = 'get';
+ case Post = 'post';
}
Assert::match(
<<<'XX'
array (3)
0 => Suit::Clubs #%d%
- 1 => Methods::GET #%d%
+ 1 => Methods::Get #%d%
| value: 'get'
- 2 => Methods::GET #%d% see above
+ 2 => Methods::Get #%d% see above
XX,
- Dumper::toText([Suit::Clubs, Methods::GET, Methods::GET]),
+ Dumper::toText([Suit::Clubs, Methods::Get, Methods::Get]),
);
diff --git a/tests/Dumper/dump().cli.phpt b/tests/Dumper/dump().cli.phpt
index 815670655..ceec74f61 100644
--- a/tests/Dumper/dump().cli.phpt
+++ b/tests/Dumper/dump().cli.phpt
@@ -5,8 +5,7 @@
*/
use Tester\Assert;
-use Tracy\Debugger;
-use Tracy\Dumper;
+use Tracy\{Debugger, Dumper};
require __DIR__ . '/../bootstrap.php';
diff --git a/tests/Dumper/dump().html.phpt b/tests/Dumper/dump().html.phpt
index 3bbded835..a4b32a3eb 100644
--- a/tests/Dumper/dump().html.phpt
+++ b/tests/Dumper/dump().html.phpt
@@ -20,12 +20,12 @@ test('html mode', function () {
dump(123);
Assert::match(
<<<'XX'
-
+
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.Bridges/PsrToTracyLoggerAdapter.phpt b/tests/Tracy.Bridges/PsrToTracyLoggerAdapter.phpt
index 0407d6896..19aea0cf9 100644
--- a/tests/Tracy.Bridges/PsrToTracyLoggerAdapter.phpt
+++ b/tests/Tracy.Bridges/PsrToTracyLoggerAdapter.phpt
@@ -8,7 +8,6 @@ use Tester\Assert;
use Tracy\Bridges\Psr\PsrToTracyLoggerAdapter;
use Tracy\ILogger;
-
require __DIR__ . '/../bootstrap.php';
class DummyPsrLogger extends Psr\Log\AbstractLogger
@@ -39,5 +38,9 @@ Assert::same([
[Psr\Log\LogLevel::WARNING, 'warning', []],
[Psr\Log\LogLevel::INFO, '123', []],
[Psr\Log\LogLevel::INFO, "array (1)\n 'x' => 'y'", []],
- [Psr\Log\LogLevel::INFO, 'Exception: Something went wrong #123 in ' . __DIR__ . DIRECTORY_SEPARATOR . 'PsrToTracyLoggerAdapter.phpt:29', ['exception' => $exception]],
+ [
+ Psr\Log\LogLevel::INFO,
+ 'Exception: Something went wrong #123 in ' . __DIR__ . DIRECTORY_SEPARATOR . 'PsrToTracyLoggerAdapter.phpt:28',
+ ['exception' => $exception],
+ ],
], $psrLogger->entries);
diff --git a/tests/Tracy.Bridges/TracyExtension.services.phpt b/tests/Tracy.Bridges/TracyExtension.services.phpt
index 009b79b53..99fca9809 100644
--- a/tests/Tracy.Bridges/TracyExtension.services.phpt
+++ b/tests/Tracy.Bridges/TracyExtension.services.phpt
@@ -5,7 +5,6 @@ use Tester\Assert;
use Tracy\Bridges\Nette\TracyExtension;
use Tracy\ILogger;
-
require __DIR__ . '/../bootstrap.php';
class CustomLogger implements ILogger
@@ -39,8 +38,8 @@ $container = new Container;
$container->initialize();
Assert::type('CustomLogger', $container->getService('tracy.logger'));
-Assert::type('Tracy\BlueScreen', $container->getService('tracy.blueScreen'));
-Assert::type('Tracy\Bar', $container->getService('tracy.bar'));
+Assert::type(Tracy\BlueScreen::class, $container->getService('tracy.blueScreen'));
+Assert::type(Tracy\Bar::class, $container->getService('tracy.bar'));
Assert::same(Tracy\Debugger::getLogger(), $container->getService('tracy.logger'));
Assert::same(Tracy\Debugger::getBlueScreen(), $container->getService('tracy.blueScreen'));
diff --git a/tests/Tracy.Bridges/TracyToPsrLoggerAdapter.phpt b/tests/Tracy.Bridges/TracyToPsrLoggerAdapter.phpt
index e7357bd49..f8a3ee551 100644
--- a/tests/Tracy.Bridges/TracyToPsrLoggerAdapter.phpt
+++ b/tests/Tracy.Bridges/TracyToPsrLoggerAdapter.phpt
@@ -8,7 +8,6 @@ use Tester\Assert;
use Tracy\Bridges\Psr\TracyToPsrLoggerAdapter;
use Tracy\ILogger;
-
require __DIR__ . '/../bootstrap.php';
class DummyTracyLogger implements ILogger
diff --git a/tests/Tracy/Bar.lazyPanels.phpt b/tests/Tracy/Bar.lazyPanels.phpt
new file mode 100644
index 000000000..a878963af
--- /dev/null
+++ b/tests/Tracy/Bar.lazyPanels.phpt
@@ -0,0 +1,186 @@
+tabCalls++;
+ return 'lazy tab';
+ }
+
+
+ public function getPanel(): string
+ {
+ $this->panelCalls++;
+ return 'lazy content
';
+ }
+}
+
+
+class LazyPanelSessionStorage implements Tracy\SessionStorage
+{
+ public array $data = [];
+
+
+ public function isAvailable(): bool
+ {
+ return true;
+ }
+
+
+ public function &getData(): array
+ {
+ return $this->data;
+ }
+}
+
+
+function createLazyPanelDefer(LazyPanelSessionStorage $storage): Tracy\DeferredContent
+{
+ $defer = new Tracy\DeferredContent($storage);
+ $defer->sendAssets();
+ return $defer;
+}
+
+
+test('lazy panel token survives an AJAX response', function () {
+ $_SERVER['HTTP_X_TRACY_AJAX'] = 'abcdef1234';
+ setHtmlMode();
+ $storage = new LazyPanelSessionStorage;
+ $defer = createLazyPanelDefer($storage);
+ $panel = new LazyPanel;
+ $bar = new Tracy\Bar;
+ $bar->addPanel($panel, 'test.panel', lazy: true);
+
+ $bar->render($defer);
+ $bar->renderLazyPanels($defer);
+
+ Assert::same(1, $panel->tabCalls);
+ Assert::same(1, $panel->panelCalls);
+ Assert::match('lazy content
%A%tracy-icons%A%', $storage->data['lazy-panels']['abcdef1234']['panels']['test-panel']);
+
+ Assert::same(1, preg_match('#^Tracy\.Debug\.loadAjax\((.*)\);\n$#s', $storage->data['setup']['abcdef1234']['code'], $match));
+ $content = json_decode($match[1], true, flags: JSON_THROW_ON_ERROR);
+ Assert::match('%A%data-tracy-lazy="abcdef1234.test-panel"%A%', $content['panels']);
+
+ unset($_SERVER['HTTP_X_TRACY_AJAX']);
+});
+
+
+test('lazy panel token survives a redirect', function () {
+ setHtmlMode();
+ header('Location: /next');
+ $storage = new LazyPanelSessionStorage;
+ $defer = createLazyPanelDefer($storage);
+ $panel = new LazyPanel;
+ $bar = new Tracy\Bar;
+ $bar->addPanel($panel, 'test.panel', lazy: true);
+
+ $bar->render($defer);
+ $bar->renderLazyPanels($defer);
+
+ $token = $defer->getRequestId() . '.test-panel';
+ Assert::same(1, $panel->tabCalls);
+ Assert::same(1, $panel->panelCalls);
+ Assert::hasKey('test-panel', $storage->data['lazy-panels'][$defer->getRequestId()]['panels']);
+ Assert::match('%A%data-tracy-lazy="' . $token . '"%A%', $storage->data['redirect'][0]['content']['panels']);
+
+ header_remove('Location');
+ http_response_code(200);
+});
+
+
+test('lazy panel content is consumed once', function () {
+ setHtmlMode();
+ $storage = new LazyPanelSessionStorage;
+ $storage->data['lazy-panels']['abcdef1234'] = [
+ 'panels' => ['test-panel' => 'lazy content
'],
+ 'time' => time(),
+ ];
+ $_GET['_tracy_bar'] = 'lazy-panel.abcdef1234.test-panel';
+ $defer = new Tracy\DeferredContent($storage);
+
+ ob_start();
+ Assert::true($defer->sendAssets());
+ $response = ob_get_clean();
+
+ Assert::same(['content' => 'lazy content
'], json_decode($response, true, flags: JSON_THROW_ON_ERROR));
+ Assert::same([], $storage->data['lazy-panels']);
+ unset($_GET['_tracy_bar']);
+});
+
+
+test('replacing a lazy panel clears the lazy flag', function () {
+ $_SERVER['HTTP_X_TRACY_AJAX'] = 'abcdef1234';
+ setHtmlMode();
+ $storage = new LazyPanelSessionStorage;
+ $defer = createLazyPanelDefer($storage);
+ $lazyPanel = new LazyPanel;
+ $eagerPanel = new LazyPanel;
+ $bar = new Tracy\Bar;
+ $bar->addPanel($lazyPanel, 'test.panel', lazy: true);
+ $bar->addPanel($eagerPanel, 'test.panel');
+
+ $bar->render($defer);
+ $bar->renderLazyPanels($defer);
+
+ Assert::same(0, $lazyPanel->tabCalls);
+ Assert::same(0, $lazyPanel->panelCalls);
+ Assert::same(1, $eagerPanel->tabCalls);
+ Assert::same(1, $eagerPanel->panelCalls);
+ Assert::hasNotKey('lazy-panels', $storage->data);
+
+ unset($_SERVER['HTTP_X_TRACY_AJAX']);
+});
+
+
+test('session cleanup preserves all panels from one request', function () {
+ $_SERVER['HTTP_X_TRACY_AJAX'] = 'abcdef1234';
+ setHtmlMode();
+ $storage = new LazyPanelSessionStorage;
+ $defer = createLazyPanelDefer($storage);
+ $bar = new Tracy\Bar;
+ for ($i = 0; $i < 11; $i++) {
+ $bar->addPanel(new LazyPanel, 'test-' . $i, lazy: true);
+ }
+
+ $bar->render($defer);
+ $bar->renderLazyPanels($defer);
+ $defer->clean();
+
+ Assert::count(1, $storage->data['lazy-panels']);
+ Assert::count(11, $storage->data['lazy-panels']['abcdef1234']['panels']);
+
+ unset($_SERVER['HTTP_X_TRACY_AJAX']);
+});
+
+
+test('lazy panels render eagerly when session storage is unavailable', function () {
+ $panel = new LazyPanel;
+ $bar = new Tracy\Bar;
+ $bar->addPanel($panel, 'test.panel', lazy: true);
+
+ $panels = (new ReflectionMethod($bar, 'renderPanels'))->invoke($bar, 'abcdef1234', '', false);
+
+ Assert::same(1, $panel->tabCalls);
+ Assert::same(1, $panel->panelCalls);
+ Assert::same('lazy content
', $panels[0]->panel);
+ Assert::null($panels[0]->lazy);
+});
diff --git a/tests/Tracy/Bar.renderAgent().ajax.phpt b/tests/Tracy/Bar.renderAgent().ajax.phpt
index e5b57611d..fe22a7440 100644
--- a/tests/Tracy/Bar.renderAgent().ajax.phpt
+++ b/tests/Tracy/Bar.renderAgent().ajax.phpt
@@ -115,7 +115,11 @@ test('', function () {
$storage = new MockSessionStorage;
$storage->data['redirect'] = [
- ['content' => ['bar' => '', 'panels' => ''], 'agent' => "Tracy Bar | 10.0 ms | 5.00 MB\n\n## Warnings\n\n- test\n", 'time' => time()],
+ [
+ 'content' => ['bar' => '', 'panels' => ''],
+ 'agent' => "Tracy Bar | 10.0 ms | 5.00 MB\n\n## Warnings\n\n- test\n",
+ 'time' => time(),
+ ],
];
$defer = createDefer($storage);
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.prepareStack.phpt b/tests/Tracy/BlueScreen.prepareStack.phpt
index b2e09e30f..45f756019 100644
--- a/tests/Tracy/BlueScreen.prepareStack.phpt
+++ b/tests/Tracy/BlueScreen.prepareStack.phpt
@@ -5,8 +5,7 @@
*/
use Tester\Assert;
-use Tracy\BlueScreen;
-use Tracy\Debugger;
+use Tracy\{BlueScreen, Debugger};
require __DIR__ . '/../bootstrap.php';
require __DIR__ . '/fixtures/transparentWrapper.php';
diff --git a/tests/Tracy/BlueScreen.renderActions().phpt b/tests/Tracy/BlueScreen.renderActions().phpt
index a0bfc53ed..e9b1f614c 100644
--- a/tests/Tracy/BlueScreen.renderActions().phpt
+++ b/tests/Tracy/BlueScreen.renderActions().phpt
@@ -61,7 +61,7 @@ Assert::with($blueScreen, function () {
);
if (PHP_SAPI !== 'cli' && PHP_SAPI !== 'phpdbg') {
- @$e->skippable = true; // deprecated since PHP 8.2
+ @$e->skippable = true; // @ dynamic properties are deprecated
Assert::same(
[
$search,
@@ -127,11 +127,11 @@ Assert::with($blueScreen, function () {
// $e->tracyAction
Assert::with($blueScreen, function () {
$e = new Exception;
- @$e->tracyAction = []; // deprecated since PHP 8.2
+ @$e->tracyAction = []; // @ dynamic properties are deprecated
Assert::count(1, $this->renderActions($e));
$e = new Exception;
- @$e->tracyAction = ['link' => 'a', 'label' => 'b']; // deprecated since PHP 8.2
+ @$e->tracyAction = ['link' => 'a', 'label' => 'b']; // @ dynamic properties are deprecated
Assert::same(
['link' => 'a', 'label' => 'b'],
$this->renderActions($e)[0],
@@ -160,7 +160,6 @@ Assert::with($blueScreen, function () {
});
-
// isset() error
class FooException extends Exception
{
diff --git a/tests/Tracy/BlueScreen.renderAgent().output.phpt b/tests/Tracy/BlueScreen.renderAgent().output.phpt
index fe87e4cc3..e5982148b 100644
--- a/tests/Tracy/BlueScreen.renderAgent().output.phpt
+++ b/tests/Tracy/BlueScreen.renderAgent().output.phpt
@@ -26,6 +26,7 @@ function third($arg1)
}
+error_clear_last(); // muted error raised while loading vendor would appear in the output
$bs = new Tracy\BlueScreen;
$bs->showEnvironment = false;
diff --git a/tests/Tracy/BlueScreen.renderAgent().phpt b/tests/Tracy/BlueScreen.renderAgent().phpt
index 03ee631cb..8f8dfd27c 100644
--- a/tests/Tracy/BlueScreen.renderAgent().phpt
+++ b/tests/Tracy/BlueScreen.renderAgent().phpt
@@ -47,6 +47,36 @@ 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);
+ // dresscode:ignore literals.concatenatedLiterals.sameLine -- the needle is split to avoid matching this line in the stack trace
+ Assert::notContains('## HTML' . ' only', $output); // panel without 'text' key gets no section
+});
+
+
+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;
@@ -57,6 +87,16 @@ test('environment section hidden when disabled', function () {
});
+test('sensitive regions are hidden in source', function () use ($blueScreen) {
+ $file = getTempDir() . '/sensitive.php';
+ $secret = bin2hex(random_bytes(8));
+ file_put_contents($file, "renderAgent(require $file);
+ Assert::notContains($secret, $output);
+ Assert::contains('$password = *****;', $output);
+});
+
+
test('ErrorException uses severity name', function () use ($blueScreen) {
$output = $blueScreen->renderAgent(new ErrorException('Bad value', 0, E_WARNING));
Assert::match('%A%# Warning: Bad value%A%', $output);
diff --git a/tests/Tracy/CodeHighlighter.hideSensitive().phpt b/tests/Tracy/CodeHighlighter.hideSensitive().phpt
new file mode 100644
index 000000000..058c2c573
--- /dev/null
+++ b/tests/Tracy/CodeHighlighter.hideSensitive().phpt
@@ -0,0 +1,34 @@
+5: throw \$e;%A%", $html);
+});
diff --git a/tests/Tracy/Debugger.E_COMPILE_ERROR.console.phpt b/tests/Tracy/Debugger.E_COMPILE_ERROR.console.phpt
index c3bc44ef8..823b7179d 100644
--- a/tests/Tracy/Debugger.E_COMPILE_ERROR.console.phpt
+++ b/tests/Tracy/Debugger.E_COMPILE_ERROR.console.phpt
@@ -33,13 +33,13 @@ register_shutdown_function(function () use (&$onFatalErrorCalled) {
Tracy is unable to log error: Logging directory is not specified.
XX : <<<'XX'
- ErrorException: Cannot re-assign $this in %a%
- Stack trace:
- #0 [internal function]: Tracy\Debugger::shutdownHandler()
- #1 {main}
- Tracy is unable to log error: Logging directory is not specified.
+ ErrorException: Cannot re-assign $this in %a%
+ Stack trace:
+ #0 [internal function]: Tracy\Debugger::shutdownHandler()
+ #1 {main}
+ Tracy is unable to log error: Logging directory is not specified.
- XX,
+ XX,
ob_get_clean(),
);
echo 'OK!'; // prevents PHP bug #62725
diff --git a/tests/Tracy/Debugger.barDump().phpt b/tests/Tracy/Debugger.barDump().phpt
index e7defddae..75a9f9ff0 100644
--- a/tests/Tracy/Debugger.barDump().phpt
+++ b/tests/Tracy/Debugger.barDump().phpt
@@ -5,8 +5,7 @@
* @outputMatch OK!
*/
-use Tester\Assert;
-use Tester\DomQuery;
+use Tester\{Assert, DomQuery};
use Tracy\Debugger;
require __DIR__ . '/../bootstrap.php';
@@ -33,7 +32,10 @@ register_shutdown_function(function () {
});
-$arr = [10, 20.2, true, false, null, 'hello