From 8b9f4bf142c0a49a035489d956fcd1c11d9745da Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 15:17:43 +0200 Subject: [PATCH 01/10] feat: Pollora and WordPress in Laravel Debugbar (phase 1) Pollora tab (what answered the request, versions, discovery, modules, theme, async actions), WP Request, WP Queries, WP Hooks and WordPress phases on the timeline; REST and admin-ajax requests stored and tagged with phpdebugbar-id; qm/* and pollora/debugbar/* actions bridged. Extension points: the Collector base class and container tags for packages, the pollora/debugbar/register action for WordPress code. --- .github/workflows/tests.yml | 64 +++++ README.md | 73 +++++ composer.json | 82 ++++++ config/debugbar-pollora.php | 64 +++++ phpstan.neon | 12 + phpunit.xml.dist | 20 ++ pint.json | 33 +++ renovate.json | 6 + src/Activation.php | 45 ++++ src/Bridges/MessageBridge.php | 104 +++++++ src/Collector.php | 196 ++++++++++++++ src/CollectorRegistrar.php | 171 ++++++++++++ src/Collectors/CallbackCollector.php | 70 +++++ src/Collectors/PolloraCollector.php | 253 ++++++++++++++++++ src/Collectors/WpHooksCollector.php | 144 ++++++++++ src/Collectors/WpQueriesCollector.php | 143 ++++++++++ src/Collectors/WpRequestCollector.php | 141 ++++++++++ src/Collectors/WpTimelineCollector.php | 48 ++++ src/Contracts/SectionProvider.php | 30 +++ src/DebugbarServiceProvider.php | 91 +++++++ src/Http/WordPressExitResponder.php | 112 ++++++++ src/Origin.php | 56 ++++ src/Recording/RequestRecorder.php | 189 +++++++++++++ src/Registry.php | 96 +++++++ src/Widget.php | 20 ++ tests/Feature/ActivationTest.php | 51 ++++ tests/Feature/CollectorRegistrarTest.php | 107 ++++++++ tests/Feature/MessageBridgeTest.php | 56 ++++ tests/Feature/PolloraCollectorTest.php | 53 ++++ tests/Feature/ServiceProviderTest.php | 31 +++ tests/Feature/WordPressExitResponderTest.php | 58 ++++ tests/Fixtures/MemoryStorage.php | 38 +++ tests/Pest.php | 34 +++ tests/TestCase.php | 36 +++ tests/Unit/CollectorTest.php | 114 ++++++++ .../Unit/Collectors/WpHooksCollectorTest.php | 46 ++++ .../Collectors/WpQueriesCollectorTest.php | 64 +++++ .../Collectors/WpRequestCollectorTest.php | 58 ++++ .../Collectors/WpTimelineCollectorTest.php | 28 ++ tests/Unit/Recording/RequestRecorderTest.php | 96 +++++++ tests/Unit/RegistryTest.php | 36 +++ 41 files changed, 3169 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/debugbar-pollora.php create mode 100644 phpstan.neon create mode 100644 phpunit.xml.dist create mode 100644 pint.json create mode 100644 renovate.json create mode 100644 src/Activation.php create mode 100644 src/Bridges/MessageBridge.php create mode 100644 src/Collector.php create mode 100644 src/CollectorRegistrar.php create mode 100644 src/Collectors/CallbackCollector.php create mode 100644 src/Collectors/PolloraCollector.php create mode 100644 src/Collectors/WpHooksCollector.php create mode 100644 src/Collectors/WpQueriesCollector.php create mode 100644 src/Collectors/WpRequestCollector.php create mode 100644 src/Collectors/WpTimelineCollector.php create mode 100644 src/Contracts/SectionProvider.php create mode 100644 src/DebugbarServiceProvider.php create mode 100644 src/Http/WordPressExitResponder.php create mode 100644 src/Origin.php create mode 100644 src/Recording/RequestRecorder.php create mode 100644 src/Registry.php create mode 100644 src/Widget.php create mode 100644 tests/Feature/ActivationTest.php create mode 100644 tests/Feature/CollectorRegistrarTest.php create mode 100644 tests/Feature/MessageBridgeTest.php create mode 100644 tests/Feature/PolloraCollectorTest.php create mode 100644 tests/Feature/ServiceProviderTest.php create mode 100644 tests/Feature/WordPressExitResponderTest.php create mode 100644 tests/Fixtures/MemoryStorage.php create mode 100644 tests/Pest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/CollectorTest.php create mode 100644 tests/Unit/Collectors/WpHooksCollectorTest.php create mode 100644 tests/Unit/Collectors/WpQueriesCollectorTest.php create mode 100644 tests/Unit/Collectors/WpRequestCollectorTest.php create mode 100644 tests/Unit/Collectors/WpTimelineCollectorTest.php create mode 100644 tests/Unit/Recording/RequestRecorderTest.php create mode 100644 tests/Unit/RegistryTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3b4e689 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,64 @@ +name: Tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + workflow_dispatch: + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + php: [8.3, 8.4] + + name: PHP ${{ matrix.php }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pdo_sqlite + coverage: none + + - name: Use the unreleased framework (until v13.35.3 is tagged; feature branch until framework#442 is merged) + run: composer require --no-update --no-interaction "pollora/framework:dev-feat/debugbar-extension-points as 13.35.3" + + - name: Install dependencies + run: composer update --prefer-stable --prefer-dist --no-interaction + + - name: Execute tests + run: vendor/bin/pest + + static-analysis: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + extensions: dom, curl, libxml, mbstring, zip + coverage: none + + - name: Use the unreleased framework (until v13.35.3 is tagged; feature branch until framework#442 is merged) + run: composer require --no-update --no-interaction "pollora/framework:dev-feat/debugbar-extension-points as 13.35.3" + + - name: Install dependencies + run: composer update --prefer-stable --prefer-dist --no-interaction + + - name: Run PHPStan + run: composer phpstan:ci + + - name: Check coding standards with Pint + run: composer lint:check diff --git a/README.md b/README.md new file mode 100644 index 0000000..0286e3f --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +

+ Latest version + Tests + License +

+ +# Pollora Debugbar + +Puts WordPress and [Pollora](https://pollora.dev) in [Laravel Debugbar](https://github.com/fruitcake/laravel-debugbar): the `$wpdb` queries next to Eloquent's, the hooks that ran, how WordPress parsed the request, which route or template answered it, and WordPress's phases on the timeline. The long-term goal is to make Query Monitor unnecessary in a Pollora project. + +## Installation + +```bash +composer require --dev pollora/debugbar +``` + +It brings `fruitcake/laravel-debugbar` with it. Nothing runs unless Laravel Debugbar is enabled for the request (`DEBUGBAR_ENABLED`, or `APP_DEBUG`; never in production or testing), and as a dev dependency it is not installed by `composer install --no-dev`. + +## Tabs + +| Tab | Origin | Shows | +| --- | --- | --- | +| Pollora | Pollora | What answered (`Route::wp()`, the template hierarchy and its view, a Laravel route, or WordPress alone), versions, discovery, modules, theme, async actions | +| WP Request | WordPress | Rewrite rule, query vars, queried object, main query, true conditionals, template and hierarchy candidates | +| WP Queries | WordPress | `$wpdb` queries with time, caller, duplicates, slow ones and the main query (turns `SAVEQUERIES` on) | +| WP Hooks | WordPress | Hooks that ran, their callbacks, and those Pollora registered | +| Timeline | WordPress | `muplugins_loaded` to `shutdown`, beside Debugbar's own measures | + +REST and admin-ajax requests end with `exit`, which Laravel Debugbar never sees: this package stores them and sends the `phpdebugbar-id` header, so they appear in the bar's request list of the page that made the call. + +Configuration: `php artisan vendor:publish --tag=debugbar-pollora-config`. + +## Adding your own data + +Tabs from Pollora, WordPress and third parties are told apart: Pollora's tab comes first, WordPress's are prefixed `WP`, and everyone else's come last. Names starting with `wp_` or `pollora` are reserved. + +**From a WordPress plugin or theme**, with no dependency on this package (without it, nothing fires the action): + +```php +add_action('pollora/debugbar/register', function ($bar): void { + $bar->table('acme_cart', 'Acme cart', fn (): array => acme_cart_rows(), origin: 'acme-shop'); + $bar->variables('acme_info', 'Acme', fn (): array => ['mode' => 'test'], origin: 'acme-shop'); + $bar->section('wp_request', 'Acme', fn (): array => ['Cart' => acme_cart_id()]); +}); + +do_action('pollora/debugbar/message', 'Cart rebuilt', 'info', ['items' => 3]); +do_action('pollora/debugbar/start', 'acme-sync'); +do_action('pollora/debugbar/stop', 'acme-sync'); +``` + +Query Monitor's `qm/debug` … `qm/emergency`, `qm/start` and `qm/stop` actions keep working too. + +**From a package or module**, extend `Pollora\Debugbar\Collector` and tag it: + +```php +final class CartCollector extends \Pollora\Debugbar\Collector +{ + public function getName(): string { return 'acme_cart'; } + public function title(): string { return 'Acme cart'; } + public function origin(): string { return 'acme-shop'; } + protected function data(): array { return ['items' => 3]; } +} + +$this->app->tag([CartCollector::class], \Pollora\Debugbar\CollectorRegistrar::COLLECTORS_TAG); +``` + +`widget()` picks `Widget::Variables`, `Widget::Table` (with `columns()`) or `Widget::Queries`. A `Pollora\Debugbar\Contracts\SectionProvider` tagged `pollora.debugbar.sections` adds a section to an existing tab. + +**With Laravel Debugbar alone**, `Debugbar::addCollector()` and `debugbar.custom_collectors` work as usual. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2c7d322 --- /dev/null +++ b/composer.json @@ -0,0 +1,82 @@ +{ + "name": "pollora/debugbar", + "description": "Laravel Debugbar for Pollora: WordPress queries, hooks, the template hierarchy and Pollora's internals in the debug bar", + "type": "library", + "license": "MIT", + "keywords": [ + "wordpress", + "laravel", + "debugbar", + "pollora", + "query-monitor" + ], + "authors": [ + { + "name": "Amphibee", + "email": "contact@amphibee.fr" + } + ], + "require": { + "php": "^8.3", + "fruitcake/laravel-debugbar": "^4.4", + "pollora/framework": "^13.35.3" + }, + "require-dev": { + "brain/monkey": "^2.7", + "laravel/pint": "^1.22", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^11.1", + "pestphp/pest": "^3.8", + "php-stubs/wordpress-stubs": "^7.1", + "phpstan/phpstan": "^2.1", + "szepeviktor/phpstan-wordpress": "^2.0" + }, + "autoload": { + "psr-4": { + "Pollora\\Debugbar\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Pollora\\Debugbar\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Pollora\\Debugbar\\DebugbarServiceProvider" + ] + } + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "cweagans/composer-patches": true, + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true, + "pollora/helper-overrider": true, + "wikimedia/composer-merge-plugin": true + } + }, + "scripts": { + "test": [ + "@test:unit", + "@phpstan", + "@lint:check" + ], + "test:unit": "pest", + "phpstan": "phpstan analyse --memory-limit=1G", + "phpstan:ci": "phpstan analyse --memory-limit=1G --no-progress", + "lint": "pint", + "lint:check": "pint --test" + }, + "scripts-descriptions": { + "test": "Run the whole quality gate as CI does", + "test:unit": "Run the Pest test suite", + "phpstan": "Run PHPStan static analysis", + "lint": "Apply Laravel Pint code style fixes", + "lint:check": "Verify code style without writing" + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/debugbar-pollora.php b/config/debugbar-pollora.php new file mode 100644 index 0000000..51b2371 --- /dev/null +++ b/config/debugbar-pollora.php @@ -0,0 +1,64 @@ + env('DEBUGBAR_POLLORA_ENABLED', true), + + /* + |-------------------------------------------------------------------------- + | Tabs + |-------------------------------------------------------------------------- + | + | pollora What answered the request, discovery, modules, theme, async + | wp_request Rewrite rule, query vars, main query, conditionals, templates + | wp_queries $wpdb queries (turns SAVEQUERIES on) + | wp_hooks Hooks that ran and the callbacks Pollora registered + | wp_timeline WordPress phases on Debugbar's timeline + | bridges pollora/debugbar/* and Query Monitor's qm/* actions + | + */ + + 'collectors' => [ + 'pollora' => env('DEBUGBAR_POLLORA_COLLECTORS_POLLORA', true), + 'wp_request' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_REQUEST', true), + 'wp_queries' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_QUERIES', true), + 'wp_hooks' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_HOOKS', true), + 'wp_timeline' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_TIMELINE', true), + 'bridges' => env('DEBUGBAR_POLLORA_COLLECTORS_BRIDGES', true), + ], + + 'options' => [ + + 'wp_queries' => [ + // Milliseconds from which a query is highlighted; null for never + 'slow_threshold' => env('DEBUGBAR_POLLORA_WP_QUERIES_SLOW_THRESHOLD', 50), + // Past this many queries, no backtrace is kept + 'soft_limit' => (int) env('DEBUGBAR_POLLORA_WP_QUERIES_SOFT_LIMIT', 100), + // Past this many queries, the rest are left out + 'hard_limit' => (int) env('DEBUGBAR_POLLORA_WP_QUERIES_HARD_LIMIT', 500), + ], + + 'wp_hooks' => [ + // Count filters too: an `all` listener runs on every apply_filters() + 'count_filters' => env('DEBUGBAR_POLLORA_WP_HOOKS_COUNT_FILTERS', false), + ], + + 'bridges' => [ + // Keep Query Monitor's qm/* logging actions working + 'query_monitor' => env('DEBUGBAR_POLLORA_BRIDGES_QUERY_MONITOR', true), + ], + ], +]; diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..65bc89d --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,12 @@ +includes: + - vendor/szepeviktor/phpstan-wordpress/extension.neon + +parameters: + level: 6 + paths: + - src + ignoreErrors: + # Pollora's services are read defensively, so that a tab survives an + # older framework: method_exists() guards PHPStan cannot narrow on. + - '#Call to function method_exists\(\) with .* will always evaluate to true#' + reportUnmatchedIgnoredErrors: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..1d0f0ba --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + tests/Unit + + + tests/Feature + + + + + src + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..c03d7ed --- /dev/null +++ b/pint.json @@ -0,0 +1,33 @@ +{ + "preset": "laravel", + "rules": { + "declare_strict_types": true, + "ordered_imports": { + "sort_algorithm": "alpha" + }, + "single_quote": true, + "array_syntax": { + "syntax": "short" + }, + "ordered_class_elements": { + "order": [ + "use_trait", + "case", + "constant_public", + "constant_protected", + "constant_private", + "property_public", + "property_protected", + "property_private", + "construct", + "method_public", + "method_protected", + "method_private" + ] + }, + "no_unused_imports": true + }, + "exclude": [ + "vendor" + ] +} \ No newline at end of file diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..39a2b6e --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ] +} diff --git a/src/Activation.php b/src/Activation.php new file mode 100644 index 0000000..97d9f7f --- /dev/null +++ b/src/Activation.php @@ -0,0 +1,45 @@ +runningInConsole()) { + return false; + } + + $config = $app->make('config'); + + if (! $config->get('debugbar-pollora.enabled', true)) { + return false; + } + + if (! LaravelDebugbar::canBeEnabled()) { + return false; + } + + // Debugbar merges its config in its own register(), which may come + // after this one: read its environment default when the key is not + // there yet. + $enabled = $config->has('debugbar.enabled') + ? value($config->get('debugbar.enabled')) + : env('DEBUGBAR_ENABLED'); + + return (bool) ($enabled ?? $config->get('app.debug')); + } +} diff --git a/src/Bridges/MessageBridge.php b/src/Bridges/MessageBridge.php new file mode 100644 index 0000000..cd98885 --- /dev/null +++ b/src/Bridges/MessageBridge.php @@ -0,0 +1,104 @@ +message(...), 10, 3); + add_action('pollora/debugbar/start', $this->start(...), 10, 2); + add_action('pollora/debugbar/stop', $this->stop(...), 10, 1); + + if (! $queryMonitor) { + return; + } + + foreach (self::LEVELS as $level) { + add_action("qm/{$level}", fn (mixed $message, array $context = []) => $this->message($message, $level, $context), 10, 2); + } + + add_action('qm/start', fn (string $name) => $this->start($name), 10, 1); + add_action('qm/stop', $this->stop(...), 10, 1); + } + + /** + * @param array $context + */ + public function message(mixed $message, string $level = 'info', array $context = []): void + { + $debugbar = ($this->debugbar)(); + + if (! $debugbar instanceof LaravelDebugbar) { + return; + } + + if ($message instanceof \Throwable) { + $debugbar->addThrowable($message); + + return; + } + + if ($message instanceof \WP_Error) { + $message = 'WP_Error: '.$message->get_error_message(); + } + + $debugbar->addMessage(is_string($message) ? $this->interpolate($message, $context) : $message, $level, $context); + } + + public function start(string $name, ?string $label = null): void + { + ($this->debugbar)()?->startMeasure($name, $label ?? $name, 'wordpress', 'WordPress'); + } + + public function stop(string $name): void + { + ($this->debugbar)()?->stopMeasure($name); + } + + /** + * PSR-3 placeholders (`{user}`) filled from the context, as Query Monitor does. + * + * @param array $context + */ + private function interpolate(string $message, array $context): string + { + $replacements = []; + + foreach ($context as $key => $value) { + if (is_scalar($value) || $value instanceof \Stringable) { + $replacements['{'.$key.'}'] = (string) $value; + } + } + + return strtr($message, $replacements); + } +} diff --git a/src/Collector.php b/src/Collector.php new file mode 100644 index 0000000..b4418d2 --- /dev/null +++ b/src/Collector.php @@ -0,0 +1,196 @@ +}> + */ + private array $sections = []; + + /** + * The tab's title in the bar. + */ + abstract public function title(): string; + + /** + * Who the data comes from: Origin::POLLORA, Origin::WORDPRESS, or the + * owner's own name for a third party (`acme-shop`). + */ + abstract public function origin(): string; + + /** + * The widget the data is shown with. + */ + public function widget(): Widget + { + return Widget::Variables; + } + + /** + * A tabler icon name, as php-debugbar 3 uses. + */ + public function icon(): string + { + return 'puzzle'; + } + + /** + * The table's columns, keyed by the row field they show (Table widget only). + * + * @return array + */ + public function columns(): array + { + return []; + } + + /** + * Where the tab sits among the tabs of its origin, from 0. + */ + public function position(): int + { + return 0; + } + + /** + * Append a section of name => value pairs to this tab (Variables widget). + * + * @param \Closure(): array $values + */ + public function addSection(string $title, \Closure $values): void + { + $this->sections[] = ['title' => $title, 'values' => $values]; + } + + /** + * @return array + */ + public function collect(): array + { + $data = $this->data(); + + return match ($this->widget()) { + Widget::Variables => $this->variables($data), + Widget::Table => [ + 'data' => ['data' => $data, 'key_map' => $this->columns()], + 'count' => count($data), + ], + Widget::Queries => $data, + }; + } + + /** + * @return array> + */ + public function getWidgets(): array + { + $name = $this->getName(); + $queries = $this->widget() === Widget::Queries; + + return [ + $name => [ + 'icon' => $this->icon(), + 'title' => $this->title(), + 'tooltip' => Origin::label($this->origin()), + 'widget' => match ($this->widget()) { + Widget::Variables => match (true) { + $this->isJsonVarDumperUsed() => 'PhpDebugBar.Widgets.JsonVariableListWidget', + $this->isHtmlVarDumperUsed() => 'PhpDebugBar.Widgets.HtmlVariableListWidget', + default => 'PhpDebugBar.Widgets.VariableListWidget', + }, + Widget::Table => 'PhpDebugBar.Widgets.TableVariableListWidget', + Widget::Queries => 'PhpDebugBar.Widgets.SQLQueriesWidget', + }, + 'map' => $queries ? $name : "{$name}.data", + 'default' => '{}', + 'order' => Origin::band($this->origin()) + $this->position(), + ], + "{$name}:badge" => [ + 'map' => $queries ? "{$name}.nb_statements" : "{$name}.count", + 'default' => 'null', + ], + ]; + } + + /** + * @return array + */ + public function getAssets(): array + { + if ($this->widget() !== Widget::Queries) { + return []; + } + + return [ + 'css' => 'widgets/sqlqueries/widget.css', + 'js' => 'widgets/sqlqueries/widget.js', + ]; + } + + public function reset(): void {} + + /** + * What the tab shows, in the shape its widget takes. + * + * Variables: name => value. Table: row key => [column => value]. + * Queries: php-debugbar's SQL statement structure. + * + * @return array + */ + abstract protected function data(): array; + + /** + * Name => value pairs as the variable list widgets render them. + * + * The JSON and HTML widgets write string values as HTML (the HTML one its + * keys too), so plain strings are escaped; arrays and objects go through + * the var dumper. + * + * @param array $values + * @return array{data: array, count: int|null} + */ + private function variables(array $values): array + { + foreach ($this->sections as $section) { + foreach (($section['values'])() as $key => $value) { + $values["{$section['title']} › {$key}"] = $value; + } + } + + $html = $this->isHtmlVarDumperUsed(); + $escape = $html || $this->isJsonVarDumperUsed(); + $formatted = []; + + foreach ($values as $key => $value) { + $label = $html ? htmlspecialchars((string) $key) : (string) $key; + + $formatted[$label] = match (true) { + is_string($value), is_int($value), is_float($value) => $escape ? htmlspecialchars((string) $value) : (string) $value, + is_bool($value) => $value ? 'yes' : 'no', + $value === null => '—', + default => $this->getDataFormatter()->formatVar($value), + }; + } + + return ['data' => $formatted, 'count' => null]; + } +} diff --git a/src/CollectorRegistrar.php b/src/CollectorRegistrar.php new file mode 100644 index 0000000..e5fb241 --- /dev/null +++ b/src/CollectorRegistrar.php @@ -0,0 +1,171 @@ +registered) { + return; + } + + $this->registered = true; + + foreach ($this->builtIns($debugbar) as $collector) { + $this->add($debugbar, $collector); + } + + foreach ($this->container->tagged(self::COLLECTORS_TAG) as $collector) { + if ($collector instanceof DataCollectorInterface) { + $this->add($debugbar, $collector); + } + } + + $registry = new Registry; + + if (function_exists('do_action')) { + do_action('pollora/debugbar/register', $registry); + } + + foreach ($registry->collectors() as $collector) { + $this->add($debugbar, $collector); + } + + foreach ($this->container->tagged(self::SECTIONS_TAG) as $provider) { + if ($provider instanceof SectionProvider) { + $this->section($debugbar, $provider->tab(), $provider->title(), $provider->values(...)); + } + } + + foreach ($registry->sections() as $section) { + $this->section($debugbar, $section['tab'], $section['title'], $section['values']); + } + } + + public function isRegistered(): bool + { + return $this->registered; + } + + /** + * @return list + */ + private function builtIns(LaravelDebugbar $debugbar): array + { + $config = $this->container->make('config'); + $on = static fn (string $name): bool => (bool) $config->get("debugbar-pollora.collectors.{$name}", true); + $collectors = []; + + if ($on('pollora')) { + $collectors[] = new PolloraCollector($this->container); + } + + if ($on('wp_request')) { + $collectors[] = new WpRequestCollector($this->recorder); + } + + if ($on('wp_queries')) { + $threshold = $config->get('debugbar-pollora.options.wp_queries.slow_threshold', 50); + + $collectors[] = new WpQueriesCollector( + is_numeric($threshold) ? (float) $threshold : null, + (int) $config->get('debugbar-pollora.options.wp_queries.soft_limit', 100), + (int) $config->get('debugbar-pollora.options.wp_queries.hard_limit', 500), + ); + } + + if ($on('wp_hooks')) { + $collectors[] = new WpHooksCollector($this->recorder, $this->polloraHookServices()); + } + + if ($on('wp_timeline') && $debugbar->hasCollector('time')) { + $time = $debugbar->getCollector('time'); + + if ($time instanceof TimeDataCollector) { + $collectors[] = new WpTimelineCollector($this->recorder, $time); + } + } + + return $collectors; + } + + /** + * @return list + */ + private function polloraHookServices(): array + { + $services = []; + + foreach ([Action::class, Filter::class] as $contract) { + if ($this->container->bound($contract)) { + $services[] = $this->container->make($contract); + } + } + + return $services; + } + + private function add(LaravelDebugbar $debugbar, DataCollectorInterface $collector): void + { + if ($debugbar->hasCollector($collector->getName())) { + $debugbar->addMessage(sprintf('pollora/debugbar: a tab named "%s" already exists; the second one was left out.', $collector->getName()), 'warning'); + + return; + } + + $debugbar->addCollector($collector); + } + + /** + * @param \Closure(): array $values + */ + private function section(LaravelDebugbar $debugbar, string $tab, string $title, \Closure $values): void + { + $collector = $debugbar->hasCollector($tab) ? $debugbar->getCollector($tab) : null; + + if ($collector instanceof Collector) { + $collector->addSection($title, $values); + } + } +} diff --git a/src/Collectors/CallbackCollector.php b/src/Collectors/CallbackCollector.php new file mode 100644 index 0000000..942bc3f --- /dev/null +++ b/src/Collectors/CallbackCollector.php @@ -0,0 +1,70 @@ + $data + * @param array $columns + */ + public function __construct( + private readonly string $name, + private readonly string $title, + private readonly string $origin, + private readonly \Closure $data, + private readonly Widget $widget = Widget::Variables, + private readonly array $columns = [], + private readonly string $icon = 'puzzle', + ) {} + + public function getName(): string + { + return $this->name; + } + + public function title(): string + { + return $this->title; + } + + public function origin(): string + { + return $this->origin; + } + + public function widget(): Widget + { + return $this->widget; + } + + public function icon(): string + { + return $this->icon; + } + + /** + * @return array + */ + public function columns(): array + { + return $this->columns; + } + + /** + * @return array + */ + protected function data(): array + { + return ($this->data)(); + } +} diff --git a/src/Collectors/PolloraCollector.php b/src/Collectors/PolloraCollector.php new file mode 100644 index 0000000..a93ca3c --- /dev/null +++ b/src/Collectors/PolloraCollector.php @@ -0,0 +1,253 @@ + + */ + protected function data(): array + { + return [ + 'Answered by' => $this->safely($this->answeredBy(...)), + 'Template' => $this->safely($this->template(...)), + 'Versions' => $this->safely($this->versions(...)), + 'Discovery' => $this->safely($this->discovery(...)), + 'Modules' => $this->safely($this->modules(...)), + 'Theme' => $this->safely($this->theme(...)), + 'Async actions' => $this->safely($this->asyncActions(...)), + ]; + } + + /** + * The route, the hierarchy, or WordPress on its own (REST, admin-ajax). + */ + private function answeredBy(): string + { + if ($this->resolution() instanceof TemplateResolution) { + return 'Template hierarchy (catch-all route)'; + } + + $route = $this->container->bound('router') ? $this->container->make('router')->current() : null; + + if ($route instanceof Route) { + if ($route instanceof WordPressRouteInterface && $route->isWordPressRoute() && $route->hasCondition()) { + return sprintf( + 'Route::wp(%s) → %s', + implode(', ', array_map(static fn (mixed $value): string => var_export($value, true), [$route->getCondition(), ...$route->getConditionParameters()])), + $route->getActionName(), + ); + } + + return sprintf('%s %s → %s', implode('|', $route->methods()), $route->uri(), $route->getActionName()); + } + + return match (true) { + defined('REST_REQUEST') && REST_REQUEST => 'WordPress REST API', + function_exists('wp_doing_ajax') && wp_doing_ajax() => 'WordPress admin-ajax', + function_exists('is_admin') && is_admin() => 'WordPress admin', + default => 'WordPress, outside any Laravel route', + }; + } + + /** + * @return array|null + */ + private function template(): ?array + { + $resolution = $this->resolution(); + + if (! $resolution instanceof TemplateResolution) { + return null; + } + + return [ + 'file' => $this->relative($resolution->template), + 'view' => $resolution->view, + 'condition' => $resolution->condition, + 'index fallback' => $resolution->usedIndexFallback, + 'outcome' => $resolution->outcome->value, + ]; + } + + /** + * @return array + */ + private function versions(): array + { + global $wp_version; + + return [ + 'pollora/framework' => InstalledVersions::isInstalled('pollora/framework') ? InstalledVersions::getPrettyVersion('pollora/framework') : null, + 'pollora/hook' => InstalledVersions::isInstalled('pollora/hook') ? InstalledVersions::getPrettyVersion('pollora/hook') : null, + 'laravel' => $this->container instanceof Application ? $this->container->version() : null, + 'wordpress' => is_string($wp_version ?? null) ? $wp_version : null, + 'php' => PHP_VERSION, + ]; + } + + /** + * @return array|null + */ + private function discovery(): ?array + { + if (! $this->container->bound(DiscoveryManager::class)) { + return null; + } + + $engine = $this->container->make(DiscoveryManager::class)->getEngine(); + + if (! method_exists($engine, 'getCacheManager')) { + return null; + } + + $cache = $engine->getCacheManager(); + $scans = method_exists($cache, 'scans') ? $cache->scans() : []; + + return [ + 'persistent cache' => $cache->isCacheEnabled() ? 'on' : 'off (debug mode)', + 'time' => sprintf('%.1f ms', array_sum(array_column($scans, 'milliseconds'))), + 'locations' => array_map(fn (array $scan): string => sprintf( + '%s · %s · %d structures · %.1f ms', + $this->relative($scan['path']), + $scan['source'], + $scan['structures'], + $scan['milliseconds'], + ), $scans), + ]; + } + + /** + * @return array|null + */ + private function modules(): ?array + { + if (! $this->container->bound(ModuleStates::class)) { + return null; + } + + $states = $this->container->make(ModuleStates::class); + + if (! $states->available()) { + return null; + } + + $modules = []; + + foreach ($states->all() as $module) { + $modules[(string) $module['name']] = ($module['enabled'] ? 'enabled' : 'disabled').($module['locked'] ? ', locked' : ''); + } + + return $modules; + } + + /** + * @return array|null + */ + private function theme(): ?array + { + if (! function_exists('wp_get_theme')) { + return null; + } + + $theme = wp_get_theme(); + + return [ + 'name' => (string) $theme->get('Name'), + 'version' => (string) $theme->get('Version'), + 'stylesheet' => $theme->get_stylesheet(), + 'template' => $theme->get_template(), + ]; + } + + /** + * @return list|null + */ + private function asyncActions(): ?array + { + if (! class_exists(AsyncInspector::class)) { + return null; + } + + $registrations = $this->container->make(AsyncInspector::class)->registrations(); + + return array_map( + static fn (object $handler): string => sprintf('%s → %s', $handler->hook, WpHooksCollector::describe($handler->handler)), + $registrations, + ) ?: null; + } + + private function resolution(): ?TemplateResolution + { + return $this->container->bound(AnsweringTemplate::class) + ? $this->container->make(AnsweringTemplate::class)->resolution() + : null; + } + + /** + * @param \Closure(): mixed $read + */ + private function safely(\Closure $read): mixed + { + try { + return $read(); + } catch (\Throwable $throwable) { + return 'unavailable: '.$throwable->getMessage(); + } + } + + private function relative(string $path): string + { + $base = function_exists('base_path') ? rtrim(base_path(), '/').'/' : ''; + + return $base !== '' && str_starts_with($path, $base) ? substr($path, strlen($base)) : $path; + } +} diff --git a/src/Collectors/WpHooksCollector.php b/src/Collectors/WpHooksCollector.php new file mode 100644 index 0000000..55dd7ea --- /dev/null +++ b/src/Collectors/WpHooksCollector.php @@ -0,0 +1,144 @@ + $polloraHooks Pollora's Action and Filter services + */ + public function __construct( + private readonly RequestRecorder $recorder, + private readonly array $polloraHooks = [], + ) {} + + public function getName(): string + { + return 'wp_hooks'; + } + + public function title(): string + { + return 'WP Hooks'; + } + + public function origin(): string + { + return Origin::WORDPRESS; + } + + public function icon(): string + { + return 'link'; + } + + public function widget(): Widget + { + return Widget::Table; + } + + public function position(): int + { + return 30; + } + + /** + * @return array + */ + public function columns(): array + { + return [ + 'calls' => 'Ran', + 'callbacks' => 'Callbacks', + 'pollora' => 'Registered by Pollora', + ]; + } + + /** + * A callback as a reader recognises it: `Class::method`, a function name or `Closure`. + */ + public static function describe(mixed $callback): string + { + return match (true) { + is_string($callback) => $callback, + is_array($callback) && isset($callback[0], $callback[1]) => (is_object($callback[0]) ? $callback[0]::class : (string) $callback[0]).'::'.(string) $callback[1], + $callback instanceof \Closure => 'Closure', + is_object($callback) => $callback::class, + default => 'unknown', + }; + } + + /** + * @return array + */ + protected function data(): array + { + global $wp_actions, $wp_filter; + + $calls = $this->recorder->hookCalls() ?? (is_array($wp_actions) ? $wp_actions : []); + $pollora = $this->polloraCallbacks(); + $rows = []; + + foreach ($calls as $hook => $count) { + $hook = (string) $hook; + $registered = 0; + + if (is_array($wp_filter) && isset($wp_filter[$hook]) && is_object($wp_filter[$hook]) && isset($wp_filter[$hook]->callbacks)) { + foreach ((array) $wp_filter[$hook]->callbacks as $callbacks) { + $registered += count((array) $callbacks); + } + } + + $rows[$hook] = [ + 'calls' => (int) $count, + 'callbacks' => $registered, + 'pollora' => implode(', ', $pollora[$hook] ?? []), + ]; + } + + return $rows; + } + + /** + * Pollora's registrations by hook, as `Class::method` names. + * + * Read from the hook services' own records: `$wp_filter` holds the same + * callbacks but cannot say who registered them. + * + * @return array> + */ + private function polloraCallbacks(): array + { + $byHook = []; + + foreach ($this->polloraHooks as $service) { + if (! method_exists($service, 'all')) { + continue; + } + + foreach ((array) $service->all() as $hook => $registrations) { + foreach ((array) $registrations as $registration) { + $callback = $registration['handler'] ?? $registration['callback'] ?? null; + $byHook[(string) $hook][] = sprintf('%s @%d', self::describe($callback), (int) ($registration['priority'] ?? 10)); + } + } + } + + return $byHook; + } +} diff --git a/src/Collectors/WpQueriesCollector.php b/src/Collectors/WpQueriesCollector.php new file mode 100644 index 0000000..60e5d26 --- /dev/null +++ b/src/Collectors/WpQueriesCollector.php @@ -0,0 +1,143 @@ + + */ + protected function data(): array + { + global $wpdb, $wp_the_query; + + $queries = is_object($wpdb) && isset($wpdb->queries) && is_array($wpdb->queries) ? $wpdb->queries : []; + $mainQuery = is_object($wp_the_query) && isset($wp_the_query->request) && is_string($wp_the_query->request) + ? trim($wp_the_query->request) + : null; + + $formatter = $this->getDataFormatter(); + $statements = []; + $total = 0.0; + + foreach (array_values($queries) as $index => $query) { + if ($index >= $this->hardLimit) { + break; + } + + $sql = trim((string) ($query[0] ?? '')); + $duration = (float) ($query[1] ?? 0); + $frames = $index < $this->softLimit ? $this->frames((string) ($query[2] ?? '')) : []; + $total += $duration; + + $statements[] = [ + 'sql' => $sql, + 'type' => 'query', + 'params' => (object) [], + 'duration' => $duration, + 'duration_str' => $formatter->formatDuration($duration), + 'memory_str' => '', + 'row_count' => null, + 'is_success' => true, + 'error_code' => null, + 'error_message' => null, + 'backtrace' => $frames, + 'filename' => ($sql === $mainQuery ? 'main query · ' : '').($frames[0] ?? ''), + 'xdebug_link' => null, + 'slow' => $this->slowThreshold !== null && $duration * 1000 >= $this->slowThreshold, + 'connection' => 'wpdb', + ]; + } + + $start = 0.0; + + foreach ($statements as $index => $statement) { + $width = $total > 0 ? $statement['duration'] / $total * 100 : 0; + $statements[$index]['start_percent'] = round($start, 3); + $statements[$index]['width_percent'] = round($width, 3); + $start += $width; + } + + return [ + 'nb_statements' => count($statements), + 'nb_excluded_statements' => max(0, count($queries) - count($statements)), + 'nb_failed_statements' => 0, + 'accumulated_duration' => $total, + 'accumulated_duration_str' => $formatter->formatDuration($total), + 'memory_usage_str' => '', + 'statements' => $statements, + ]; + } + + /** + * WordPress's caller string, innermost call first. + * + * `$wpdb` stores the stack outermost first, joined by commas + * (`require('wp-blog-header.php'), wp, WP->main, …`). + * + * @return list + */ + private function frames(string $caller): array + { + if ($caller === '') { + return []; + } + + return array_values(array_reverse(array_filter(array_map(trim(...), explode(',', $caller))))); + } +} diff --git a/src/Collectors/WpRequestCollector.php b/src/Collectors/WpRequestCollector.php new file mode 100644 index 0000000..3c40458 --- /dev/null +++ b/src/Collectors/WpRequestCollector.php @@ -0,0 +1,141 @@ + + */ + protected function data(): array + { + global $wp, $wp_query, $wp_the_query; + + $data = []; + + if (is_object($wp)) { + $data['Request'] = (string) ($wp->request ?? ''); + $data['Matched rule'] = $wp->matched_rule ?? null; + $data['Matched query'] = $wp->matched_query ?? null; + $data['Query vars'] = (array) ($wp->query_vars ?? []); + } + + $data['Queried object'] = $this->queriedObject(); + + if (is_object($wp_the_query) && isset($wp_the_query->query_vars)) { + $data['Main query'] = array_filter( + (array) $wp_the_query->query_vars, + static fn (mixed $value): bool => $value !== '' && $value !== [] && $value !== null && $value !== false && $value !== 0, + ); + $data['Main query results'] = sprintf( + '%d of %d found, %d page(s)', + (int) ($wp_the_query->post_count ?? 0), + (int) ($wp_the_query->found_posts ?? 0), + (int) ($wp_the_query->max_num_pages ?? 0), + ); + } + + // Conditional tags complain through _doing_it_wrong() before the query exists. + if (isset($wp_query)) { + $data['Conditionals'] = implode(', ', $this->trueConditionals()); + } + + $data['Template'] = $this->recorder->template() !== null ? $this->relative($this->recorder->template()) : null; + $data['Template hierarchy'] = $this->recorder->hierarchies() !== [] ? $this->recorder->hierarchies() : null; + + return $data; + } + + /** + * @return list + */ + private function trueConditionals(): array + { + $true = []; + + foreach (self::CONDITIONALS as $conditional) { + if (function_exists($conditional) && $conditional()) { + $true[] = $conditional.'()'; + } + } + + return $true; + } + + private function queriedObject(): ?string + { + if (! function_exists('get_queried_object')) { + return null; + } + + $object = get_queried_object(); + + return match (true) { + $object instanceof \WP_Post => sprintf('post #%d (%s) %s', $object->ID, $object->post_type, $object->post_title), + $object instanceof \WP_Term => sprintf('term #%d (%s) %s', $object->term_id, $object->taxonomy, $object->name), + $object instanceof \WP_User => sprintf('user #%d %s', $object->ID, $object->display_name), + $object instanceof \WP_Post_Type => sprintf('post type %s', $object->name), + default => null, + }; + } + + private function relative(string $path): string + { + $base = function_exists('base_path') ? rtrim(base_path(), '/').'/' : ''; + + return $base !== '' && str_starts_with($path, $base) ? substr($path, strlen($base)) : $path; + } +} diff --git a/src/Collectors/WpTimelineCollector.php b/src/Collectors/WpTimelineCollector.php new file mode 100644 index 0000000..ba2d375 --- /dev/null +++ b/src/Collectors/WpTimelineCollector.php @@ -0,0 +1,48 @@ +recorder->phases(); + $bootedAt = $this->recorder->bootedAt(); + + if ($bootedAt !== null && isset($phases['wp_loaded'])) { + $this->time->addMeasure('WordPress loading', $bootedAt, $phases['wp_loaded']['end'], [], 'wordpress', 'WordPress'); + } + + foreach ($phases as $action => $phase) { + $this->time->addMeasure("{$action} callbacks", $phase['start'], $phase['end'], [], 'wordpress', 'WordPress'); + } + + return ['phases' => count($phases)]; + } +} diff --git a/src/Contracts/SectionProvider.php b/src/Contracts/SectionProvider.php new file mode 100644 index 0000000..e463a59 --- /dev/null +++ b/src/Contracts/SectionProvider.php @@ -0,0 +1,30 @@ + value pairs to an existing tab. + * + * For data that belongs beside Pollora's or WordPress's own rather than in a + * tab of its own — a shop's cart next to the request, say. Register it with + * the `pollora.debugbar.sections` container tag. + */ +interface SectionProvider +{ + /** + * The collector name of the tab to add to: `pollora`, `wp_request`… + */ + public function tab(): string; + + /** + * The section's title, shown before each of its names. + */ + public function title(): string; + + /** + * @return array + */ + public function values(): array; +} diff --git a/src/DebugbarServiceProvider.php b/src/DebugbarServiceProvider.php new file mode 100644 index 0000000..fef541a --- /dev/null +++ b/src/DebugbarServiceProvider.php @@ -0,0 +1,91 @@ +mergeConfigFrom(__DIR__.'/../config/debugbar-pollora.php', 'debugbar-pollora'); + + $this->app->singleton(RequestRecorder::class); + $this->app->singleton(CollectorRegistrar::class); + + if (! Activation::shouldRun($this->app)) { + return; + } + + $config = $this->app->make('config'); + + if ($config->get('debugbar-pollora.collectors.wp_queries', true) && ! defined('SAVEQUERIES')) { + define('SAVEQUERIES', true); + } + + $this->app->make('events')->listen(WordPressBooting::class, function () use ($config): void { + $this->app->make(RequestRecorder::class)->install( + countAllHooks: (bool) $config->get('debugbar-pollora.options.wp_hooks.count_filters', false), + ); + + if ($config->get('debugbar-pollora.collectors.bridges', true)) { + (new MessageBridge($this->collectingDebugbar(...)))->install( + queryMonitor: (bool) $config->get('debugbar-pollora.options.bridges.query_monitor', true), + ); + } + + (new WordPressExitResponder( + $this->collectingDebugbar(...), + fn (LaravelDebugbar $debugbar) => $this->app->make(CollectorRegistrar::class)->register($debugbar), + ))->install(); + }); + } + + public function boot(): void + { + $this->publishes([ + __DIR__.'/../config/debugbar-pollora.php' => $this->app->configPath('debugbar-pollora.php'), + ], 'debugbar-pollora-config'); + + if (! Activation::shouldRun($this->app)) { + return; + } + + $this->app->booted(function (): void { + $debugbar = $this->collectingDebugbar(); + + if ($debugbar instanceof LaravelDebugbar) { + $this->app->make(CollectorRegistrar::class)->register($debugbar); + } + }); + } + + /** + * The bar, when it is enabled and has booted; null otherwise. + */ + private function collectingDebugbar(): ?LaravelDebugbar + { + if (! $this->app->bound(LaravelDebugbar::class)) { + return null; + } + + $debugbar = $this->app->make(LaravelDebugbar::class); + + return $debugbar->isEnabled() && $debugbar->isCollecting() ? $debugbar : null; + } +} diff --git a/src/Http/WordPressExitResponder.php b/src/Http/WordPressExitResponder.php new file mode 100644 index 0000000..03529ff --- /dev/null +++ b/src/Http/WordPressExitResponder.php @@ -0,0 +1,112 @@ +tagRestResponse(...), 10, 1); + add_action('admin_init', $this->tagAjaxResponse(...), 0, 0); + add_action('shutdown', $this->collect(...), 0, 0); + } + + /** + * Name the stored request in the REST response's headers. + */ + public function tagRestResponse(mixed $response): mixed + { + $debugbar = $this->storingDebugbar(); + + if ($debugbar instanceof LaravelDebugbar && $response instanceof \WP_REST_Response) { + $response->header('phpdebugbar-id', $debugbar->getCurrentRequestId()); + } + + return $response; + } + + /** + * Name the stored request before admin-ajax prints anything. + * + * `admin_init` runs in admin-ajax.php before the `wp_ajax_*` handler, so + * headers can still be sent. + */ + public function tagAjaxResponse(): void + { + $debugbar = $this->storingDebugbar(); + + if ($debugbar instanceof LaravelDebugbar && $this->isAjax() && ! headers_sent()) { + header('phpdebugbar-id: '.$debugbar->getCurrentRequestId()); + } + } + + /** + * Collect and store what Laravel's own lifecycle never will. + * + * Only for the requests that exit before Laravel finishes: on a page + * Laravel renders, Pollora fires `shutdown` from a middleware, well before + * the response is done. + */ + public function collect(): void + { + if (! $this->isRest() && ! $this->isAjax()) { + return; + } + + $debugbar = $this->storingDebugbar(); + + if (! $debugbar instanceof LaravelDebugbar) { + return; + } + + ($this->prepare)($debugbar); + + // getData() collects, and stores, only when nothing has been collected yet. + $debugbar->getData(); + } + + private function storingDebugbar(): ?LaravelDebugbar + { + $debugbar = ($this->debugbar)(); + + return $debugbar instanceof LaravelDebugbar && $debugbar->isDataPersisted() ? $debugbar : null; + } + + private function isRest(): bool + { + return defined('REST_REQUEST') && REST_REQUEST; + } + + private function isAjax(): bool + { + return function_exists('wp_doing_ajax') && wp_doing_ajax(); + } +} diff --git a/src/Origin.php b/src/Origin.php new file mode 100644 index 0000000..e8156f7 --- /dev/null +++ b/src/Origin.php @@ -0,0 +1,56 @@ + 1000, + self::WORDPRESS => 2000, + default => 3000, + }; + } + + /** + * Whether a collector name is kept for this package's own tabs. + * + * A third party picking `wp_queries` would replace a built-in tab — or + * collide with it, since Debugbar refuses a name twice. + */ + public static function isReservedName(string $name): bool + { + return str_starts_with($name, 'wp_') || str_starts_with($name, 'pollora'); + } + + /** + * How the origin reads in the bar. + */ + public static function label(string $origin): string + { + return match ($origin) { + self::POLLORA => 'Pollora', + self::WORDPRESS => 'WordPress', + default => $origin, + }; + } +} diff --git a/src/Recording/RequestRecorder.php b/src/Recording/RequestRecorder.php new file mode 100644 index 0000000..7dfbaed --- /dev/null +++ b/src/Recording/RequestRecorder.php @@ -0,0 +1,189 @@ + + */ + private array $phases = []; + + /** + * Candidate lists WordPress built, by template type, in the order asked. + * + * @var array> + */ + private array $hierarchies = []; + + private ?string $templateInclude = null; + + /** + * How many times each hook ran, filters included (only with filter counts on). + * + * @var array + */ + private array $hookCalls = []; + + /** + * Start listening. Call once WordPress's plugin API is loaded and before + * `wp-settings.php` runs. + */ + public function install(bool $countAllHooks = false): void + { + if ($this->installed || ! function_exists('add_action')) { + return; + } + + $this->installed = true; + $this->bootedAt = microtime(true); + + foreach (self::NOTABLE_ACTIONS as $action) { + add_action($action, fn () => $this->phaseStarts($action), PHP_INT_MIN, 0); + add_action($action, fn () => $this->phaseEnds($action), PHP_INT_MAX, 0); + } + + foreach (self::TEMPLATE_TYPES as $type) { + add_filter("{$type}_template_hierarchy", fn (mixed $templates): mixed => $this->hierarchy($type, $templates), PHP_INT_MAX); + } + + add_filter('template_include', fn (mixed $template): mixed => $this->recordTemplate($template), PHP_INT_MAX); + + if ($countAllHooks) { + add_action('all', function (string $hook): void { + $this->countCall($hook); + }, 10, 1); + } + } + + public function isInstalled(): bool + { + return $this->installed; + } + + /** + * When WordPress started loading (right before `wp-settings.php`). + */ + public function bootedAt(): ?float + { + return $this->bootedAt; + } + + /** + * @return array + */ + public function phases(): array + { + return $this->phases; + } + + /** + * @return array> + */ + public function hierarchies(): array + { + return $this->hierarchies; + } + + /** + * The template `template_include` settled on, after every other filter. + */ + public function template(): ?string + { + return $this->templateInclude; + } + + /** + * @return array|null Null when filter counts are off + */ + public function hookCalls(): ?array + { + return $this->hookCalls === [] ? null : $this->hookCalls; + } + + public function reset(): void + { + $this->phases = []; + $this->hierarchies = []; + $this->templateInclude = null; + $this->hookCalls = []; + $this->bootedAt = null; + } + + private function phaseStarts(string $action): void + { + $this->phases[$action] ??= ['start' => microtime(true), 'end' => microtime(true)]; + } + + private function phaseEnds(string $action): void + { + if (isset($this->phases[$action])) { + $this->phases[$action]['end'] = microtime(true); + } + } + + private function hierarchy(string $type, mixed $templates): mixed + { + if (is_array($templates)) { + $this->hierarchies[$type] = array_values(array_map(strval(...), array_filter($templates, is_scalar(...)))); + } + + return $templates; + } + + private function recordTemplate(mixed $template): mixed + { + if (is_string($template) && $template !== '') { + $this->templateInclude = $template; + } + + return $template; + } + + private function countCall(string $hook): void + { + $this->hookCalls[$hook] = ($this->hookCalls[$hook] ?? 0) + 1; + } +} diff --git a/src/Registry.php b/src/Registry.php new file mode 100644 index 0000000..0b5e1ae --- /dev/null +++ b/src/Registry.php @@ -0,0 +1,96 @@ +table('acme_cart', 'Acme cart', fn (): array => acme_cart_rows(), origin: 'acme-shop'); + * $bar->section('wp_request', 'Acme', fn (): array => ['Cart' => acme_cart_id()]); + * }); + */ +final class Registry +{ + /** + * @var list + */ + private array $collectors = []; + + /** + * @var list}> + */ + private array $sections = []; + + /** + * A tab of name => value pairs. + * + * @param \Closure(): array $values + */ + public function variables(string $name, string $title, \Closure $values, string $origin): static + { + $this->collectors[] = new CallbackCollector($this->checked($name), $title, $origin, $values); + + return $this; + } + + /** + * A tab of rows sharing the same columns. + * + * @param \Closure(): array> $rows Row key => [column => value] + * @param array $columns Column key => label; empty to show every field + */ + public function table(string $name, string $title, \Closure $rows, string $origin, array $columns = []): static + { + $this->collectors[] = new CallbackCollector($this->checked($name), $title, $origin, $rows, Widget::Table, $columns); + + return $this; + } + + /** + * A section appended to an existing tab (`pollora`, `wp_request`, or another package's). + * + * @param \Closure(): array $values + */ + public function section(string $tab, string $title, \Closure $values): static + { + $this->sections[] = ['tab' => $tab, 'title' => $title, 'values' => $values]; + + return $this; + } + + /** + * @return list + */ + public function collectors(): array + { + return $this->collectors; + } + + /** + * @return list}> + */ + public function sections(): array + { + return $this->sections; + } + + private function checked(string $name): string + { + if (Origin::isReservedName($name)) { + throw new \InvalidArgumentException(sprintf( + 'The debug bar tab name "%s" is reserved: names starting with "wp_" or "pollora" belong to pollora/debugbar. Prefix it with your own name instead.', + $name, + )); + } + + return $name; + } +} diff --git a/src/Widget.php b/src/Widget.php new file mode 100644 index 0000000..8519f05 --- /dev/null +++ b/src/Widget.php @@ -0,0 +1,20 @@ +setValue($app, false); +} + +beforeEach(function (): void { + config(['app.debug' => true, 'debugbar.enabled' => null]); + $this->app['env'] = 'local'; +}); + +it('runs where Laravel Debugbar would be enabled', function (): void { + servingTheWeb($this->app); + + expect(Activation::shouldRun($this->app))->toBeTrue(); +}); + +it('never runs in the console', function (): void { + expect(Activation::shouldRun($this->app))->toBeFalse(); +}); + +it('never runs in production, as Debugbar itself refuses to', function (): void { + servingTheWeb($this->app); + $this->app['env'] = 'production'; + + expect(Activation::shouldRun($this->app))->toBeFalse(); +}); + +it("follows Debugbar's own switch over APP_DEBUG", function (): void { + servingTheWeb($this->app); + config(['debugbar.enabled' => false]); + + expect(Activation::shouldRun($this->app))->toBeFalse(); +}); + +it("can be turned off while keeping Debugbar's other tabs", function (): void { + servingTheWeb($this->app); + config(['debugbar-pollora.enabled' => false]); + + expect(Activation::shouldRun($this->app))->toBeFalse(); +}); diff --git a/tests/Feature/CollectorRegistrarTest.php b/tests/Feature/CollectorRegistrarTest.php new file mode 100644 index 0000000..fc20153 --- /dev/null +++ b/tests/Feature/CollectorRegistrarTest.php @@ -0,0 +1,107 @@ +collectingDebugbar(); + + registrar()->register($debugbar); + + foreach (['pollora', 'wp_request', 'wp_queries', 'wp_hooks', 'wp_timeline'] as $name) { + expect($debugbar->hasCollector($name))->toBeTrue(); + } +}); + +it('leaves out the tabs turned off in its config', function (): void { + config(['debugbar-pollora.collectors.wp_hooks' => false]); + $debugbar = $this->collectingDebugbar(); + + registrar()->register($debugbar); + + expect($debugbar->hasCollector('wp_hooks'))->toBeFalse(); +}); + +it('adds collectors tagged in the container (level 2)', function (): void { + $this->app->instance('acme.collector', new MessagesCollector('acme_log')); + $this->app->tag('acme.collector', CollectorRegistrar::COLLECTORS_TAG); + $debugbar = $this->collectingDebugbar(); + + registrar()->register($debugbar); + + expect($debugbar->hasCollector('acme_log'))->toBeTrue(); +}); + +it('adds the tabs and sections WordPress code registers through the action (level 3)', function (): void { + Functions\when('do_action')->alias(function (string $hook, mixed ...$arguments): void { + if ($hook === 'pollora/debugbar/register') { + $arguments[0] + ->table('acme_cart', 'Acme cart', fn (): array => ['apple' => ['qty' => 3]], 'acme-shop') + ->section('pollora', 'Acme', fn (): array => ['cart' => 'abc']); + } + }); + $debugbar = $this->collectingDebugbar(); + + registrar()->register($debugbar); + + expect($debugbar->hasCollector('acme_cart'))->toBeTrue() + ->and($debugbar->getCollector('acme_cart')->getWidgets()['acme_cart']['order'])->toBe(3000) + ->and(array_keys($debugbar->getCollector('pollora')->collect()['data']))->toContain('Acme › cart'); +}); + +it('adds sections from tagged providers', function (): void { + $this->app->instance('acme.section', new class implements SectionProvider + { + public function tab(): string + { + return 'wp_request'; + } + + public function title(): string + { + return 'Acme'; + } + + public function values(): array + { + return ['locale' => 'fr_FR']; + } + }); + $this->app->tag('acme.section', CollectorRegistrar::SECTIONS_TAG); + Functions\when('get_queried_object')->justReturn(null); + $debugbar = $this->collectingDebugbar(); + + registrar()->register($debugbar); + + expect($debugbar->getCollector('wp_request')->collect()['data'])->toHaveKey('Acme › locale', 'fr_FR'); +}); + +it('keeps the first of two tabs with the same name and says so', function (): void { + $this->app->instance('acme.one', new MessagesCollector('acme_log')); + $this->app->instance('acme.two', new MessagesCollector('acme_log')); + $this->app->tag(['acme.one', 'acme.two'], CollectorRegistrar::COLLECTORS_TAG); + $debugbar = $this->collectingDebugbar(); + + registrar()->register($debugbar); + + expect(array_column($debugbar->getMessagesCollector()->getMessages(), 'message')) + ->toContain('pollora/debugbar: a tab named "acme_log" already exists; the second one was left out.'); +}); + +it('registers once, however often it is asked', function (): void { + $debugbar = $this->collectingDebugbar(); + + registrar()->register($debugbar); + registrar()->register($debugbar); + + expect(registrar()->isRegistered())->toBeTrue(); +}); diff --git a/tests/Feature/MessageBridgeTest.php b/tests/Feature/MessageBridgeTest.php new file mode 100644 index 0000000..e1acbb0 --- /dev/null +++ b/tests/Feature/MessageBridgeTest.php @@ -0,0 +1,56 @@ +getMessagesCollector()->getMessages(), 'message'); +} + +it("fills Query Monitor's placeholders from the context", function (): void { + $debugbar = $this->collectingDebugbar(); + + (new MessageBridge(fn () => $debugbar))->message('Cart {cart} rebuilt', 'warning', ['cart' => 42]); + + expect(messagesOf($debugbar))->toContain('Cart 42 rebuilt'); +}); + +it('sends throwables to the exceptions tab', function (): void { + $debugbar = $this->collectingDebugbar(); + + (new MessageBridge(fn () => $debugbar))->message(new RuntimeException('boom')); + + expect($debugbar->getExceptionsCollector()->getExceptions())->toHaveCount(1); +}); + +it('times between start and stop on the timeline', function (): void { + $debugbar = $this->collectingDebugbar(); + $bridge = new MessageBridge(fn () => $debugbar); + + $bridge->start('acme-sync'); + $bridge->stop('acme-sync'); + + expect(array_column($debugbar->getTimeCollector()->getMeasures(), 'label'))->toContain('acme-sync'); +}); + +it('drops messages while the bar is not collecting', function (): void { + (new MessageBridge(fn (): null => null))->message('lost'); +})->throwsNoExceptions(); + +it("listens to Query Monitor's actions only when asked to", function (): void { + (new MessageBridge(fn (): null => null))->install(queryMonitor: false); + + expect(has_action('pollora/debugbar/message'))->toBeTrue() + ->and(has_action('qm/debug'))->toBeFalse(); +}); + +it('listens to every Query Monitor level', function (): void { + (new MessageBridge(fn (): null => null))->install(); + + foreach ([...array_map(fn (string $level): string => "qm/{$level}", MessageBridge::LEVELS), 'qm/start', 'qm/stop'] as $action) { + expect(has_action($action))->toBeTrue(); + } +}); diff --git a/tests/Feature/PolloraCollectorTest.php b/tests/Feature/PolloraCollectorTest.php new file mode 100644 index 0000000..807f0ed --- /dev/null +++ b/tests/Feature/PolloraCollectorTest.php @@ -0,0 +1,53 @@ +setDataFormatter(new DataFormatter)->collect()['data']; +} + +it('says the template hierarchy answered, and with which view', function (): void { + $answering = new AnsweringTemplate; + $answering->record(new TemplateResolution( + template: base_path('themes/acme/resources/views/single.blade.php'), + condition: 'is_single', + view: 'single', + usedIndexFallback: false, + outcome: TemplateOutcome::View, + )); + $this->app->instance(AnsweringTemplate::class, $answering); + + $data = polloraData(); + + expect($data['Answered by'])->toBe('Template hierarchy (catch-all route)') + ->and($data['Template'])->toContain('themes/acme/resources/views/single.blade.php') + ->and($data['Template'])->toContain('is_single'); +}); + +it('names the Laravel route that answered', function (): void { + Route::get('/shop/search', fn () => 'ok'); + $this->get('/shop/search'); + + expect(polloraData()['Answered by'])->toBe('GET|HEAD shop/search → Closure'); +}); + +it('says WordPress answered alone when no route did', function (): void { + Brain\Monkey\Functions\stubs(['is_admin' => false, 'wp_doing_ajax' => false]); + + expect(polloraData()['Answered by'])->toBe('WordPress, outside any Laravel route'); +}); + +it('keeps the tab when one part cannot be read', function (): void { + $this->app->bind(AnsweringTemplate::class, fn () => throw new RuntimeException('not bound here')); + + expect(polloraData()['Answered by'])->toBe('unavailable: not bound here') + ->and(polloraData()['Versions'])->toContain(PHP_VERSION); +}); diff --git a/tests/Feature/ServiceProviderTest.php b/tests/Feature/ServiceProviderTest.php new file mode 100644 index 0000000..b9913e0 --- /dev/null +++ b/tests/Feature/ServiceProviderTest.php @@ -0,0 +1,31 @@ +toBeTrue() + ->and(config('debugbar-pollora.options.wp_hooks.count_filters'))->toBeFalse(); +}); + +it('installs nothing for WordPress when it does not run (console, here)', function (): void { + event(new WordPressBooting(lightweight: false)); + + expect(app(RequestRecorder::class)->isInstalled())->toBeFalse(); +}); + +it('installs the recorder once WordPress announces itself, when it runs', function (): void { + (new ReflectionProperty($this->app, 'isRunningInConsole'))->setValue($this->app, false); + config(['app.debug' => true]); + $this->app['env'] = 'local'; + + // register() decides once, as it does on a real request + (new DebugbarServiceProvider($this->app))->register(); + event(new WordPressBooting(lightweight: false)); + + expect(app(RequestRecorder::class)->isInstalled())->toBeTrue() + ->and(defined('SAVEQUERIES') && SAVEQUERIES)->toBeTrue(); +}); diff --git a/tests/Feature/WordPressExitResponderTest.php b/tests/Feature/WordPressExitResponderTest.php new file mode 100644 index 0000000..9ab4f6c --- /dev/null +++ b/tests/Feature/WordPressExitResponderTest.php @@ -0,0 +1,58 @@ + */ + public array $headers = []; + + public function header(string $key, string $value): void + { + $this->headers[$key] = $value; + } + } +} + +it('names the stored request in a REST response', function (): void { + $debugbar = $this->collectingDebugbar(); + + $response = (new WordPressExitResponder(fn () => $debugbar, fn () => null))->tagRestResponse(new WP_REST_Response); + + expect($response->headers)->toBe(['phpdebugbar-id' => $debugbar->getCurrentRequestId()]); +}); + +it('leaves REST responses alone when nothing is stored to open later', function (): void { + $debugbar = $this->collectingDebugbar(); + $debugbar->setStorage(null); + + $response = (new WordPressExitResponder(fn () => $debugbar, fn () => null))->tagRestResponse(new WP_REST_Response); + + expect($response->headers)->toBe([]); +}); + +it('collects and stores an admin-ajax request at shutdown, after adding the tabs', function (): void { + Functions\when('wp_doing_ajax')->justReturn(true); + $debugbar = $this->collectingDebugbar(); + $prepared = false; + + (new WordPressExitResponder(fn () => $debugbar, function () use (&$prepared): void { + $prepared = true; + }))->collect(); + + expect($prepared)->toBeTrue() + ->and($debugbar->getStorage()->saved)->toHaveKey($debugbar->getCurrentRequestId()); +}); + +it('leaves pages Laravel renders to Laravel, since Pollora fires shutdown early there', function (): void { + Functions\when('wp_doing_ajax')->justReturn(false); + $debugbar = $this->collectingDebugbar(); + + (new WordPressExitResponder(fn () => $debugbar, fn () => null))->collect(); + + expect($debugbar->getStorage()->saved)->toBe([]); +}); diff --git a/tests/Fixtures/MemoryStorage.php b/tests/Fixtures/MemoryStorage.php new file mode 100644 index 0000000..1aa1205 --- /dev/null +++ b/tests/Fixtures/MemoryStorage.php @@ -0,0 +1,38 @@ +> */ + public array $saved = []; + + public function save(string $id, array $data): void + { + $this->saved[$id] = $data; + } + + public function get(string $id): array + { + return $this->saved[$id] ?? []; + } + + public function find(array $filters = [], int $max = 20, int $offset = 0): array + { + return []; + } + + public function clear(): void + { + $this->saved = []; + } + + public function prune(int $hours = 24): void {} +} diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..a1cb5e6 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,34 @@ +beforeEach(function (): void { + Brain\Monkey\setUp(); + }) + ->afterEach(function (): void { + Brain\Monkey\tearDown(); + }) + ->in('Unit'); + +uses(TestCase::class) + ->beforeEach(function (): void { + Brain\Monkey\setUp(); + }) + ->afterEach(function (): void { + Brain\Monkey\tearDown(); + }) + ->in('Feature'); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..08c2fc0 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,36 @@ + + */ + protected function getPackageProviders($app): array + { + return [DebugbarServiceProvider::class, PolloraDebugbarServiceProvider::class]; + } + + /** + * The bar, switched on as it would be on a debug page, storing in memory. + * + * Testbench runs in the console, where Debugbar stays off by itself. + */ + protected function collectingDebugbar(): LaravelDebugbar + { + $debugbar = $this->app->make(LaravelDebugbar::class); + $debugbar->enable(); + $debugbar->setStorage(new MemoryStorage); + + return $debugbar; + } +} diff --git a/tests/Unit/CollectorTest.php b/tests/Unit/CollectorTest.php new file mode 100644 index 0000000..407b8d2 --- /dev/null +++ b/tests/Unit/CollectorTest.php @@ -0,0 +1,114 @@ + $data + */ +function collectorShowing(array $data, Widget $widget = Widget::Variables, string $origin = 'acme-shop'): Collector +{ + return new class($data, $widget, $origin) extends Collector + { + /** + * @param array $rows + */ + public function __construct(private readonly array $rows, private readonly Widget $shape, private readonly string $from) {} + + public function getName(): string + { + return 'acme_cart'; + } + + public function title(): string + { + return 'Acme cart'; + } + + public function origin(): string + { + return $this->from; + } + + public function widget(): Widget + { + return $this->shape; + } + + public function columns(): array + { + return ['qty' => 'Quantity']; + } + + protected function data(): array + { + return $this->rows; + } + }; +} + +describe('variables', function (): void { + it('escapes strings the HTML widget would write as markup', function (): void { + $collector = collectorShowing(['name' => '']) + ->setDataFormatter(new HtmlDataFormatter); + + $data = $collector->collect()['data']; + + expect($data)->toBe(['<b>name</b>' => '<script>alert(1)</script>']); + }); + + it('escapes string values for the JSON widget, which renders them as HTML too', function (): void { + $collector = collectorShowing(['name' => 'x'])->setDataFormatter(new JsonDataFormatter); + + expect($collector->collect()['data'])->toBe(['name' => '<i>x</i>']) + ->and($collector->getWidgets()['acme_cart']['widget'])->toBe('PhpDebugBar.Widgets.JsonVariableListWidget'); + }); + + it('reads booleans and nulls as words', function (): void { + $collector = collectorShowing(['cached' => true, 'cart' => null])->setDataFormatter(new DataFormatter); + + expect($collector->collect()['data'])->toBe(['cached' => 'yes', 'cart' => '—']); + }); + + it('appends sections after its own values, each name prefixed by its title', function (): void { + $collector = collectorShowing(['own' => 'value'])->setDataFormatter(new DataFormatter); + $collector->addSection('Acme', fn (): array => ['cart' => '42']); + + expect(array_keys($collector->collect()['data']))->toBe(['own', 'Acme › cart']); + }); +}); + +describe('widgets', function (): void { + it('places the tab in its origin band and names the origin in its tooltip', function (): void { + $widgets = collectorShowing([], Widget::Variables, Origin::WORDPRESS)->getWidgets(); + + expect($widgets['acme_cart']['order'])->toBe(2000) + ->and($widgets['acme_cart']['tooltip'])->toBe('WordPress') + ->and($widgets['acme_cart']['title'])->toBe('Acme cart'); + }); + + it('puts third parties after Pollora and WordPress', function (): void { + expect(Origin::band('acme-shop'))->toBeGreaterThan(Origin::band(Origin::WORDPRESS)) + ->and(Origin::band(Origin::WORDPRESS))->toBeGreaterThan(Origin::band(Origin::POLLORA)); + }); + + it('shapes rows for the table widget, with their columns and a count for the badge', function (): void { + $collector = collectorShowing(['apple' => ['qty' => 3]], Widget::Table); + + expect($collector->collect())->toBe([ + 'data' => ['data' => ['apple' => ['qty' => 3]], 'key_map' => ['qty' => 'Quantity']], + 'count' => 1, + ])->and($collector->getWidgets()['acme_cart:badge']['map'])->toBe('acme_cart.count'); + }); + + it('asks for the SQL widget assets only when it shows queries', function (): void { + expect(collectorShowing([], Widget::Queries)->getAssets())->toHaveKey('js', 'widgets/sqlqueries/widget.js') + ->and(collectorShowing([])->getAssets())->toBe([]); + }); +}); diff --git a/tests/Unit/Collectors/WpHooksCollectorTest.php b/tests/Unit/Collectors/WpHooksCollectorTest.php new file mode 100644 index 0000000..8b1dd35 --- /dev/null +++ b/tests/Unit/Collectors/WpHooksCollectorTest.php @@ -0,0 +1,46 @@ + [['hook' => 'init', 'callback' => [new ArrayObject, 'count'], 'priority' => 5, 'args' => 0]]]; + } +} + +beforeEach(function (): void { + $GLOBALS['wp_actions'] = ['muplugins_loaded' => 1, 'init' => 1, 'wp_head' => 2]; + $GLOBALS['wp_filter'] = [ + 'init' => (object) ['callbacks' => [5 => ['a' => [], 'b' => []], 10 => ['c' => []]]], + ]; +}); + +afterEach(function (): void { + unset($GLOBALS['wp_actions'], $GLOBALS['wp_filter']); +}); + +it('lists the actions that ran, in order, with their callbacks', function (): void { + $rows = (new WpHooksCollector(new RequestRecorder))->collect()['data']['data']; + + expect(array_keys($rows))->toBe(['muplugins_loaded', 'init', 'wp_head']) + ->and($rows['init']['callbacks'])->toBe(3) + ->and($rows['wp_head']['calls'])->toBe(2); +}); + +it('names the callbacks Pollora registered, with their priority', function (): void { + $rows = (new WpHooksCollector(new RequestRecorder, [new FakePolloraHooks]))->collect()['data']['data']; + + expect($rows['init']['pollora'])->toBe('ArrayObject::count @5') + ->and($rows['wp_head']['pollora'])->toBe(''); +}); + +it('describes callbacks the way a reader recognises them', function (): void { + expect(WpHooksCollector::describe('wp_head'))->toBe('wp_head') + ->and(WpHooksCollector::describe(['Acme\\Cart', 'boot']))->toBe('Acme\\Cart::boot') + ->and(WpHooksCollector::describe(fn (): null => null))->toBe('Closure'); +}); diff --git a/tests/Unit/Collectors/WpQueriesCollectorTest.php b/tests/Unit/Collectors/WpQueriesCollectorTest.php new file mode 100644 index 0000000..155b7a6 --- /dev/null +++ b/tests/Unit/Collectors/WpQueriesCollectorTest.php @@ -0,0 +1,64 @@ + [ + ['SELECT * FROM wp_options', 0.002, "require('wp-blog-header.php'), wp_load_alloptions", 1.0], + ['SELECT * FROM wp_posts WHERE ID = 1', 0.08, "require('wp-blog-header.php'), wp, WP->main, WP_Query->get_posts", 1.1], + ['SELECT * FROM wp_options', 0.001, '', 1.2], + ]]; + $GLOBALS['wp_the_query'] = (object) ['request' => 'SELECT * FROM wp_posts WHERE ID = 1']; +}); + +afterEach(function (): void { + unset($GLOBALS['wpdb'], $GLOBALS['wp_the_query']); +}); + +function queriesFrom(WpQueriesCollector $collector): array +{ + return $collector->setDataFormatter(new DataFormatter)->collect(); +} + +it('lists every query with its time and the wpdb connection', function (): void { + $data = queriesFrom(new WpQueriesCollector); + + expect($data['nb_statements'])->toBe(3) + ->and($data['statements'][0]['sql'])->toBe('SELECT * FROM wp_options') + ->and($data['statements'][0]['connection'])->toBe('wpdb') + ->and($data['accumulated_duration'])->toEqualWithDelta(0.083, 0.0001); +}); + +it('shows the caller innermost first, and marks the main query', function (): void { + $statement = queriesFrom(new WpQueriesCollector)['statements'][1]; + + expect($statement['backtrace'][0])->toBe('WP_Query->get_posts') + ->and($statement['filename'])->toBe('main query · WP_Query->get_posts'); +}); + +it('flags queries at or over the slow threshold', function (): void { + $statements = queriesFrom(new WpQueriesCollector(slowThreshold: 50))['statements']; + + expect(array_column($statements, 'slow'))->toBe([false, true, false]); +}); + +it('drops backtraces past the soft limit and queries past the hard one', function (): void { + $data = queriesFrom(new WpQueriesCollector(softLimit: 1, hardLimit: 2)); + + expect($data['nb_statements'])->toBe(2) + ->and($data['nb_excluded_statements'])->toBe(1) + ->and($data['statements'][1]['backtrace'])->toBe([]); +}); + +it('shows nothing when WordPress kept no queries', function (): void { + unset($GLOBALS['wpdb']); + + expect(queriesFrom(new WpQueriesCollector)['nb_statements'])->toBe(0); +}); diff --git a/tests/Unit/Collectors/WpRequestCollectorTest.php b/tests/Unit/Collectors/WpRequestCollectorTest.php new file mode 100644 index 0000000..cd44a3c --- /dev/null +++ b/tests/Unit/Collectors/WpRequestCollectorTest.php @@ -0,0 +1,58 @@ + 'hello-world', + 'matched_rule' => '([^/]+)(?:/([0-9]+))?/?$', + 'matched_query' => 'name=hello-world&page=', + 'query_vars' => ['name' => 'hello-world'], + ]; + $GLOBALS['wp_the_query'] = (object) [ + 'query_vars' => ['name' => 'hello-world', 'paged' => 0, 'post_type' => ''], + 'post_count' => 1, + 'found_posts' => 1, + 'max_num_pages' => 0, + ]; + + Functions\when('get_queried_object')->justReturn(null); +}); + +afterEach(function (): void { + unset($GLOBALS['wp'], $GLOBALS['wp_the_query'], $GLOBALS['wp_query']); +}); + +function requestData(?RequestRecorder $recorder = null): array +{ + return (new WpRequestCollector($recorder ?? new RequestRecorder))->setDataFormatter(new DataFormatter)->collect()['data']; +} + +it('shows how WordPress parsed the request', function (): void { + $data = requestData(); + + expect($data['Request'])->toBe('hello-world') + ->and($data['Matched query'])->toBe('name=hello-world&page=') + ->and($data['Main query results'])->toBe('1 of 1 found, 0 page(s)'); +}); + +it('lists only the conditionals that are true', function (): void { + $GLOBALS['wp_query'] = new stdClass; + + foreach (WpRequestCollector::CONDITIONALS as $conditional) { + Functions\when($conditional)->justReturn(in_array($conditional, ['is_single', 'is_singular'], true)); + } + + expect(requestData()['Conditionals'])->toBe('is_single(), is_singular()'); +}); + +it('leaves conditionals out before the main query exists, where they would complain', function (): void { + Functions\expect('is_single')->never(); + + expect(requestData())->not->toHaveKey('Conditionals'); +}); diff --git a/tests/Unit/Collectors/WpTimelineCollectorTest.php b/tests/Unit/Collectors/WpTimelineCollectorTest.php new file mode 100644 index 0000000..eb17c89 --- /dev/null +++ b/tests/Unit/Collectors/WpTimelineCollectorTest.php @@ -0,0 +1,28 @@ +zeroOrMoreTimes()->whenHappen(function (callable $callback, int $priority) use (&$callbacks): void { + $callbacks[$priority] = $callback; + }); + + $recorder = new RequestRecorder; + $recorder->install(); + $callbacks[PHP_INT_MIN](); + $callbacks[PHP_INT_MAX](); + + $time = new TimeDataCollector(microtime(true) - 1); + (new WpTimelineCollector($recorder, $time))->collect(); + + $labels = array_column($time->collect()['measures'], 'label'); + + expect($labels)->toContain('WordPress loading') + ->and($labels)->toContain('wp_loaded callbacks'); +}); diff --git a/tests/Unit/Recording/RequestRecorderTest.php b/tests/Unit/Recording/RequestRecorderTest.php new file mode 100644 index 0000000..d1e7105 --- /dev/null +++ b/tests/Unit/Recording/RequestRecorderTest.php @@ -0,0 +1,96 @@ +> + */ +function captureHooks(): ArrayObject +{ + $captured = new ArrayObject; + + foreach (RequestRecorder::NOTABLE_ACTIONS as $action) { + Actions\expectAdded($action)->zeroOrMoreTimes()->whenHappen(function (callable $callback, int $priority) use ($captured, $action): void { + $captured["{$action}@{$priority}"] = $callback; + }); + } + + Filters\expectAdded('single_template_hierarchy')->zeroOrMoreTimes()->whenHappen(function (callable $callback) use ($captured): void { + $captured['single_template_hierarchy'] = $callback; + }); + + Filters\expectAdded('template_include')->zeroOrMoreTimes()->whenHappen(function (callable $callback) use ($captured): void { + $captured['template_include'] = $callback; + }); + + Actions\expectAdded('all')->zeroOrMoreTimes()->whenHappen(function (callable $callback) use ($captured): void { + $captured['all'] = $callback; + }); + + return $captured; +} + +it('times each notable action from its first callback to its last', function (): void { + $hooks = captureHooks(); + $recorder = new RequestRecorder; + $recorder->install(); + + $hooks['init@'.PHP_INT_MIN](); + usleep(1000); + $hooks['init@'.PHP_INT_MAX](); + + $phase = $recorder->phases()['init']; + + expect($phase['end'])->toBeGreaterThan($phase['start']) + ->and($recorder->bootedAt())->toBeLessThanOrEqual($phase['start']); +}); + +it('keeps the hierarchy WordPress built and the template it settled on, unchanged', function (): void { + $hooks = captureHooks(); + $recorder = new RequestRecorder; + $recorder->install(); + + $returned = $hooks['single_template_hierarchy'](['single-post.php', 'single.php']); + $template = $hooks['template_include']('/theme/single.blade.php'); + + expect($returned)->toBe(['single-post.php', 'single.php']) + ->and($template)->toBe('/theme/single.blade.php') + ->and($recorder->hierarchies())->toBe(['single' => ['single-post.php', 'single.php']]) + ->and($recorder->template())->toBe('/theme/single.blade.php'); +}); + +it('counts every hook call only when asked to', function (): void { + $hooks = captureHooks(); + $recorder = new RequestRecorder; + $recorder->install(countAllHooks: true); + + $hooks['all']('the_content'); + $hooks['all']('the_content'); + + expect($recorder->hookCalls())->toBe(['the_content' => 2]); +}); + +it('does not listen to every hook by default', function (): void { + $hooks = captureHooks(); + (new RequestRecorder)->install(); + + expect(isset($hooks['all']))->toBeFalse(); +}); + +it('installs once', function (): void { + Actions\expectAdded('init')->twice(); + captureHooks(); + + $recorder = new RequestRecorder; + $recorder->install(); + $recorder->install(); + + expect($recorder->isInstalled())->toBeTrue(); +}); diff --git a/tests/Unit/RegistryTest.php b/tests/Unit/RegistryTest.php new file mode 100644 index 0000000..3fcf9ac --- /dev/null +++ b/tests/Unit/RegistryTest.php @@ -0,0 +1,36 @@ +variables('acme_info', 'Acme', fn (): array => ['a' => 1], 'acme-shop') + ->table('acme_cart', 'Acme cart', fn (): array => [], 'acme-shop', ['qty' => 'Quantity']); + + [$info, $cart] = $registry->collectors(); + + expect($info->getName())->toBe('acme_info') + ->and($info->origin())->toBe('acme-shop') + ->and($cart->widget())->toBe(Widget::Table) + ->and($cart->columns())->toBe(['qty' => 'Quantity']); +}); + +it('keeps sections for the tab they name', function (): void { + $registry = (new Registry)->section('wp_request', 'Acme', fn (): array => []); + + expect($registry->sections()[0]['tab'])->toBe('wp_request') + ->and($registry->sections()[0]['title'])->toBe('Acme'); +}); + +it("refuses a third party a name that belongs to Pollora's tabs", function (string $name): void { + (new Registry)->variables($name, 'Mine', fn (): array => [], 'acme-shop'); +})->with(['wp_queries', 'wp_cart', 'pollora', 'pollora_extra'])->throws(InvalidArgumentException::class); + +it('knows which names are reserved', function (): void { + expect(Origin::isReservedName('wp_hooks'))->toBeTrue() + ->and(Origin::isReservedName('acme_wp'))->toBeFalse(); +}); From 13ab213ba1d6c9a5d078d91e782921f1a397bc68 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 15:29:09 +0200 Subject: [PATCH 02/10] fix: describe Pollora's closures and use icons php-debugbar ships Most of Pollora's callbacks are closures, which all read as "Closure": first-class callables now read as Class::method and real closures as the class and file:line they were written in. Tab icons are taken from php-debugbar's own set; any other name renders as an empty square. --- src/Collector.php | 6 ++-- src/Collectors/CallbackCollector.php | 2 +- src/Collectors/PolloraCollector.php | 2 +- src/Collectors/WpHooksCollector.php | 35 +++++++++++++++++-- src/Collectors/WpRequestCollector.php | 2 +- src/Registry.php | 8 ++--- .../Unit/Collectors/WpHooksCollectorTest.php | 3 +- 7 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/Collector.php b/src/Collector.php index b4418d2..e07d535 100644 --- a/src/Collector.php +++ b/src/Collector.php @@ -46,11 +46,13 @@ public function widget(): Widget } /** - * A tabler icon name, as php-debugbar 3 uses. + * The tab's icon: one of the icons php-debugbar ships (`box`, `database`, + * `link`, `leaf`, `search`, `table`, `tags`, `clock`, `bolt`, `flag`…; + * see its resources/icons.css). Any other name shows an empty square. */ public function icon(): string { - return 'puzzle'; + return 'box'; } /** diff --git a/src/Collectors/CallbackCollector.php b/src/Collectors/CallbackCollector.php index 942bc3f..960d1a0 100644 --- a/src/Collectors/CallbackCollector.php +++ b/src/Collectors/CallbackCollector.php @@ -24,7 +24,7 @@ public function __construct( private readonly \Closure $data, private readonly Widget $widget = Widget::Variables, private readonly array $columns = [], - private readonly string $icon = 'puzzle', + private readonly string $icon = 'box', ) {} public function getName(): string diff --git a/src/Collectors/PolloraCollector.php b/src/Collectors/PolloraCollector.php index a93ca3c..604e957 100644 --- a/src/Collectors/PolloraCollector.php +++ b/src/Collectors/PolloraCollector.php @@ -48,7 +48,7 @@ public function origin(): string public function icon(): string { - return 'hexagon'; + return 'leaf'; } /** diff --git a/src/Collectors/WpHooksCollector.php b/src/Collectors/WpHooksCollector.php index 55dd7ea..310b4b7 100644 --- a/src/Collectors/WpHooksCollector.php +++ b/src/Collectors/WpHooksCollector.php @@ -70,14 +70,15 @@ public function columns(): array } /** - * A callback as a reader recognises it: `Class::method`, a function name or `Closure`. + * A callback as a reader recognises it: `Class::method`, a function name, + * or where a closure was written. */ public static function describe(mixed $callback): string { return match (true) { is_string($callback) => $callback, is_array($callback) && isset($callback[0], $callback[1]) => (is_object($callback[0]) ? $callback[0]::class : (string) $callback[0]).'::'.(string) $callback[1], - $callback instanceof \Closure => 'Closure', + $callback instanceof \Closure => self::describeClosure($callback), is_object($callback) => $callback::class, default => 'unknown', }; @@ -114,6 +115,34 @@ protected function data(): array return $rows; } + /** + * `$this->boot(...)` reads as `Class::boot`; a real closure as the class + * it was written in and its file and line, since "Closure" alone says + * nothing when most of Pollora's callbacks are closures. + */ + private static function describeClosure(\Closure $closure): string + { + $reflection = new \ReflectionFunction($closure); + $scope = $reflection->getClosureScopeClass()?->getName(); + + if (! str_contains($reflection->getName(), '{closure')) { + return $scope !== null ? "{$scope}::{$reflection->getName()}" : $reflection->getName(); + } + + $where = basename((string) $reflection->getFileName()).':'.$reflection->getStartLine(); + + return $scope !== null ? "closure in {$scope} ({$where})" : "closure ({$where})"; + } + + private static function priority(int $priority): string + { + return match ($priority) { + PHP_INT_MAX => 'last', + PHP_INT_MIN => 'first', + default => (string) $priority, + }; + } + /** * Pollora's registrations by hook, as `Class::method` names. * @@ -134,7 +163,7 @@ private function polloraCallbacks(): array foreach ((array) $service->all() as $hook => $registrations) { foreach ((array) $registrations as $registration) { $callback = $registration['handler'] ?? $registration['callback'] ?? null; - $byHook[(string) $hook][] = sprintf('%s @%d', self::describe($callback), (int) ($registration['priority'] ?? 10)); + $byHook[(string) $hook][] = sprintf('%s @%s', self::describe($callback), self::priority((int) ($registration['priority'] ?? 10))); } } } diff --git a/src/Collectors/WpRequestCollector.php b/src/Collectors/WpRequestCollector.php index 3c40458..034c093 100644 --- a/src/Collectors/WpRequestCollector.php +++ b/src/Collectors/WpRequestCollector.php @@ -49,7 +49,7 @@ public function origin(): string public function icon(): string { - return 'brand-wordpress'; + return 'search'; } public function position(): int diff --git a/src/Registry.php b/src/Registry.php index 0b5e1ae..fd0fead 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -34,9 +34,9 @@ final class Registry * * @param \Closure(): array $values */ - public function variables(string $name, string $title, \Closure $values, string $origin): static + public function variables(string $name, string $title, \Closure $values, string $origin, string $icon = 'box'): static { - $this->collectors[] = new CallbackCollector($this->checked($name), $title, $origin, $values); + $this->collectors[] = new CallbackCollector($this->checked($name), $title, $origin, $values, icon: $icon); return $this; } @@ -47,9 +47,9 @@ public function variables(string $name, string $title, \Closure $values, string * @param \Closure(): array> $rows Row key => [column => value] * @param array $columns Column key => label; empty to show every field */ - public function table(string $name, string $title, \Closure $rows, string $origin, array $columns = []): static + public function table(string $name, string $title, \Closure $rows, string $origin, array $columns = [], string $icon = 'table'): static { - $this->collectors[] = new CallbackCollector($this->checked($name), $title, $origin, $rows, Widget::Table, $columns); + $this->collectors[] = new CallbackCollector($this->checked($name), $title, $origin, $rows, Widget::Table, $columns, $icon); return $this; } diff --git a/tests/Unit/Collectors/WpHooksCollectorTest.php b/tests/Unit/Collectors/WpHooksCollectorTest.php index 8b1dd35..ea7bc07 100644 --- a/tests/Unit/Collectors/WpHooksCollectorTest.php +++ b/tests/Unit/Collectors/WpHooksCollectorTest.php @@ -42,5 +42,6 @@ public function all(): array it('describes callbacks the way a reader recognises them', function (): void { expect(WpHooksCollector::describe('wp_head'))->toBe('wp_head') ->and(WpHooksCollector::describe(['Acme\\Cart', 'boot']))->toBe('Acme\\Cart::boot') - ->and(WpHooksCollector::describe(fn (): null => null))->toBe('Closure'); + ->and(WpHooksCollector::describe(fn (): null => null))->toMatch('/^closure in P\\\\Tests\\\\Unit\\\\Collectors\\\\WpHooksCollectorTest\\S* \\(WpHooksCollectorTest\\.php:\\d+\\)$/') + ->and(WpHooksCollector::describe((new ArrayObject)->count(...)))->toBe('ArrayObject::count'); }); From bf2a7ef70e7c31c131d63e197fbf78fba6e517c3 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 15:35:46 +0200 Subject: [PATCH 03/10] test: end-to-end check on a real Pollora site A Playwright job installs the skeleton under DDEV with this package, adds a fixture mu-plugin that extends the bar both ways (actions and a tagged Collector), and checks the tabs, their order, the answering template, wpdb queries, a REST call listed by the bar and the third party data. --- .github/workflows/tests.yml | 120 +++++++++++++++++++++++++++ .gitignore | 3 + tests/e2e/fixtures/acme-debugbar.php | 57 +++++++++++++ tests/e2e/package-lock.json | 79 ++++++++++++++++++ tests/e2e/package.json | 16 ++++ tests/e2e/playwright.config.ts | 39 +++++++++ tests/e2e/specs/debugbar.spec.ts | 84 +++++++++++++++++++ 7 files changed, 398 insertions(+) create mode 100644 tests/e2e/fixtures/acme-debugbar.php create mode 100644 tests/e2e/package-lock.json create mode 100644 tests/e2e/package.json create mode 100644 tests/e2e/playwright.config.ts create mode 100644 tests/e2e/specs/debugbar.spec.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3b4e689..c4c4edc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -62,3 +62,123 @@ jobs: - name: Check coding standards with Pint run: composer lint:check + + e2e: + name: E2E + runs-on: ubuntu-latest + needs: tests + + steps: + - name: Checkout skeleton + uses: actions/checkout@v4 + with: + repository: Pollora/pollora + + - name: Checkout this package + uses: actions/checkout@v4 + with: + path: packages/debugbar + + - name: Setup DDEV + uses: ddev/github-action-setup-ddev@v1 + with: + autostart: false + + - name: Configure DDEV + run: | + ddev config \ + --project-name=pollora-debugbar \ + --project-type=wordpress \ + --docroot=public \ + --php-version=8.4 \ + --webserver-type=apache-fpm \ + --database=mariadb:10.11 \ + --nodejs-version=22 \ + --disable-settings-management + + - name: Start DDEV + run: ddev start + + - name: Prepare .env + run: | + cp .env.example .env + sed -i \ + -e 's#^APP_URL=.*#APP_URL=https://pollora-debugbar.ddev.site#' \ + -e 's#^APP_ENV=.*#APP_ENV=local#' \ + -e 's#^APP_DEBUG=.*#APP_DEBUG=true#' \ + -e 's#^DB_CONNECTION=.*#DB_CONNECTION=mysql#' \ + -e 's/^# DB_HOST=.*/DB_HOST=db/' \ + -e 's/^# DB_PORT=.*/DB_PORT=3306/' \ + -e 's/^# DB_DATABASE=.*/DB_DATABASE=db/' \ + -e 's/^# DB_USERNAME=.*/DB_USERNAME=db/' \ + -e 's/^# DB_PASSWORD=.*/DB_PASSWORD=db/' \ + .env + + # Until v13.35.3 is tagged, the framework comes from framework#442's branch. + - name: Install the skeleton with this package checkout + run: | + ddev composer config repositories.debugbar '{"type": "path", "url": "packages/debugbar", "options": {"symlink": false, "versions": {"pollora/debugbar": "0.1.0"}}}' + ddev composer require "pollora/framework:dev-feat/debugbar-extension-points as 13.35.3" --with-all-dependencies --no-interaction --no-progress --no-scripts + ddev composer require --dev pollora/debugbar:0.1.0 --with-all-dependencies --no-interaction --no-progress + + - name: Generate application key + run: ddev exec php artisan key:generate --no-interaction + + - name: Install WordPress + run: | + ddev exec php artisan pollora:install --install \ + --title="Pollora" \ + --description="Pollora Debugbar CI" \ + --admin-user=admin \ + --admin-email=admin@example.com \ + --admin-password=pollora-ci-password \ + --locale=en_US \ + --public=false \ + --no-interaction + + - name: Add the plugin and package fixtures + run: | + mkdir -p public/content/mu-plugins + cp packages/debugbar/tests/e2e/fixtures/acme-debugbar.php public/content/mu-plugins/ + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: packages/debugbar/tests/e2e/package-lock.json + + - name: Install the browser tests + working-directory: packages/debugbar/tests/e2e + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Trust the site's certificate + run: | + if command -v mkcert >/dev/null && [ -f "$(mkcert -CAROOT)/rootCA.pem" ]; then + echo "NODE_EXTRA_CA_CERTS=$(mkcert -CAROOT)/rootCA.pem" >> "$GITHUB_ENV" + else + echo "::warning::No mkcert authority on the runner; certificate checks are off for this disposable site" + echo "NODE_TLS_REJECT_UNAUTHORIZED=0" >> "$GITHUB_ENV" + fi + + - name: Browser tests + working-directory: packages/debugbar/tests/e2e + env: + E2E_HOME_URL: https://pollora-debugbar.ddev.site + run: npx playwright test + + - name: Upload the browser test report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-report + path: | + packages/debugbar/tests/e2e/playwright-report + packages/debugbar/tests/e2e/test-results + retention-days: 14 + + - name: Laravel log + if: failure() + run: tail -n 200 storage/logs/laravel.log || true diff --git a/.gitignore b/.gitignore index b95361f..00eced4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ composer.lock patches.lock.json .idea/ .phpunit.result.cache +tests/e2e/node_modules/ +tests/e2e/playwright-report/ +tests/e2e/test-results/ diff --git a/tests/e2e/fixtures/acme-debugbar.php b/tests/e2e/fixtures/acme-debugbar.php new file mode 100644 index 0000000..29ea479 --- /dev/null +++ b/tests/e2e/fixtures/acme-debugbar.php @@ -0,0 +1,57 @@ +table('acme_cart', 'Acme cart', fn (): array => ['apple' => ['qty' => 3]], 'acme-shop', ['qty' => 'Qty']); + $bar->section('wp_request', 'Acme', fn (): array => ['cart id' => 'c-42']); +}); + +add_action('init', function (): void { + do_action('pollora/debugbar/message', 'Acme cart {id} rebuilt', 'info', ['id' => 'c-42']); + do_action('qm/warning', 'Written for Query Monitor'); +}); + +if (class_exists(Collector::class)) { + final class AcmeOrdersCollector extends Collector + { + public function getName(): string + { + return 'acme_orders'; + } + + public function title(): string + { + return 'Acme orders'; + } + + public function origin(): string + { + return 'acme-shop'; + } + + public function widget(): Widget + { + return Widget::Variables; + } + + protected function data(): array + { + return ['open orders' => 2]; + } + } + + app()->tag([AcmeOrdersCollector::class], CollectorRegistrar::COLLECTORS_TAG); +} diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 0000000..699474f --- /dev/null +++ b/tests/e2e/package-lock.json @@ -0,0 +1,79 @@ +{ + "name": "pollora-debugbar-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pollora-debugbar-e2e", + "devDependencies": { + "@playwright/test": "1.63.0", + "@types/node": "22.20.4" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000..0cb383a --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "pollora-debugbar-e2e", + "private": true, + "description": "Browser tests run against a real Pollora site with pollora/debugbar installed", + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.63.0", + "@types/node": "22.20.4" + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 0000000..cf25bf5 --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,39 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { defineConfig, devices } from '@playwright/test'; + +/** + * Browser tests run against a real Pollora site with pollora/debugbar installed + * and the fixtures in ./fixtures copied to its mu-plugins. + * + * E2E_HOME_URL the site's front end, e.g. https://pollora-debugbar.ddev.site + */ +const homeUrl = process.env.E2E_HOME_URL ?? 'https://pollora-debugbar.ddev.site'; + +// DDEV serves HTTPS with a mkcert certificate: trust its authority when present. +if (! process.env.NODE_EXTRA_CA_CERTS) { + try { + const rootCa = `${execFileSync('mkcert', ['-CAROOT'], { encoding: 'utf8' }).trim()}/rootCA.pem`; + + if (existsSync(rootCa)) { + process.env.NODE_EXTRA_CA_CERTS = rootCa; + } + } catch { + // No mkcert: the site's certificate must already be trusted. + } +} + +export default defineConfig({ + testDir: './specs', + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + outputDir: './test-results', + use: { + baseURL: `${homeUrl}/`, + ignoreHTTPSErrors: true, + trace: 'retain-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/tests/e2e/specs/debugbar.spec.ts b/tests/e2e/specs/debugbar.spec.ts new file mode 100644 index 0000000..be0df36 --- /dev/null +++ b/tests/e2e/specs/debugbar.spec.ts @@ -0,0 +1,84 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** + * The debug bar on a real Pollora page: Pollora's and WordPress's tabs, a REST + * call made from the page, and the data third parties add. + */ + +type DebugBar = { + controls: Record; + datasets: Record>; +}; + +async function debugbar(page: Page): Promise { + await page.waitForFunction(() => 'phpdebugbar' in window && Object.keys((window as any).phpdebugbar.datasets).length > 0); + + return page.evaluate(() => { + const bar = (window as any).phpdebugbar; + + return { controls: Object.fromEntries(Object.keys(bar.controls).map((name) => [name, true])), datasets: bar.datasets }; + }); +} + +function firstDataset(bar: DebugBar): Record { + return Object.values(bar.datasets)[0]; +} + +test('puts the Pollora tab, then the WordPress tabs, after Laravel’s', async ({ page }) => { + await page.goto(''); + const names = Object.keys((await debugbar(page)).controls); + + const pollora = names.indexOf('pollora'); + + expect(pollora).toBeGreaterThan(names.indexOf('queries')); + expect(names.indexOf('wp_request')).toBeGreaterThan(pollora); + expect(names.indexOf('wp_queries')).toBeGreaterThan(pollora); + expect(names.indexOf('wp_hooks')).toBeGreaterThan(pollora); + expect(names.indexOf('acme_cart')).toBeGreaterThan(names.indexOf('wp_hooks')); +}); + +test('says the template hierarchy answered the front page, and with which view', async ({ page }) => { + await page.goto(''); + const pollora = firstDataset(await debugbar(page)).pollora.data; + + expect(pollora['Answered by']).toBe('Template hierarchy (catch-all route)'); + expect(JSON.stringify(pollora.Template)).toContain('"view":"home"'); + + await page.locator('.phpdebugbar-tab', { hasText: 'Pollora' }).click(); + await expect(page.locator('.phpdebugbar-panel.phpdebugbar-active')).toContainText('Answered by'); +}); + +test('shows the queries WordPress ran and the hooks that fired', async ({ page }) => { + await page.goto(''); + const data = firstDataset(await debugbar(page)); + + expect(data.wp_queries.nb_statements).toBeGreaterThan(0); + expect(data.wp_queries.statements[0].connection).toBe('wpdb'); + expect(data.wp_hooks.data.data.init.calls).toBeGreaterThan(0); +}); + +test('lists a REST call made from the page among the bar’s requests', async ({ page }) => { + await page.goto(''); + await debugbar(page); + + const status = await page.evaluate(async () => (await fetch('/wp-json/wp/v2/posts')).status); + expect(status).toBe(200); + + await expect.poll(async () => page.evaluate( + () => Object.values((window as any).phpdebugbar.datasets).map((dataset: any) => dataset.__meta?.uri), + )).toContain('/wp-json/wp/v2/posts'); +}); + +test('shows what a plugin and a package add, labelled as theirs', async ({ page }) => { + await page.goto(''); + const bar = await debugbar(page); + const data = firstDataset(bar); + + expect(bar.controls).toHaveProperty('acme_cart'); + expect(bar.controls).toHaveProperty('acme_orders'); + expect(data.acme_cart.data.data.apple.qty).toBe(3); + expect(data.wp_request.data['Acme › cart id']).toBe('c-42'); + expect(data.messages.messages.map((message: any) => message.message)).toEqual( + expect.arrayContaining(['Acme cart c-42 rebuilt', 'Written for Query Monitor']), + ); +}); From 734be914955f531bab8228b87657dc1f838e2d26 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 16:15:26 +0200 Subject: [PATCH 04/10] feat: Query Monitor parity (phase 2) Full backtrace, error and rows for each wpdb query through core filters (no wpdb subclass, so any db.php drop-in keeps working) and attribution of queries, HTTP calls, transients and checks to a component. New tabs: WP HTTP, WP Cache, WP Capabilities (aggregated, on by default), WP Blocks (with block bindings), WP Assets (with Vite builds), WP Languages and Doctor (pollora:doctor's web checks on demand). Redirects made with wp_redirect() are stacked for the next page. The Pollora tab adds the async actions queued, WordPress constants and drop-ins. --- README.md | 15 +- config/debugbar-pollora.php | 21 ++ resources/doctor-widget.js | 86 ++++++++ src/Collector.php | 17 +- src/CollectorRegistrar.php | 49 ++++- src/Collectors/DoctorCollector.php | 82 ++++++++ src/Collectors/PolloraCollector.php | 60 +++++- src/Collectors/WpAssetsCollector.php | 186 +++++++++++++++++ src/Collectors/WpBlocksCollector.php | 138 +++++++++++++ src/Collectors/WpCacheCollector.php | 102 ++++++++++ src/Collectors/WpCapabilitiesCollector.php | 88 +++++++++ src/Collectors/WpHttpCollector.php | 86 ++++++++ src/Collectors/WpLanguagesCollector.php | 80 ++++++++ src/Collectors/WpQueriesCollector.php | 39 +++- src/Collectors/WpRequestCollector.php | 9 +- src/DebugbarServiceProvider.php | 58 +++++- src/Http/DoctorController.php | 49 +++++ src/Http/WordPressExitResponder.php | 63 +++++- src/Recording/AsyncRecorder.php | 52 +++++ src/Recording/BlockRecorder.php | 101 ++++++++++ src/Recording/CacheRecorder.php | 62 ++++++ src/Recording/CapabilityRecorder.php | 103 ++++++++++ src/Recording/HttpRecorder.php | 115 +++++++++++ src/Recording/LanguageRecorder.php | 59 ++++++ src/Recording/QueryTracer.php | 126 ++++++++++++ src/Support/Components.php | 170 ++++++++++++++++ tests/Feature/CollectorRegistrarTest.php | 2 +- tests/Feature/DoctorTest.php | 75 +++++++ .../Collectors/WpQueriesCollectorTest.php | 31 +++ tests/Unit/Recording/QueryTracerTest.php | 74 +++++++ tests/Unit/Recording/RecordersTest.php | 187 ++++++++++++++++++ tests/Unit/Support/ComponentsTest.php | 55 ++++++ tests/e2e/fixtures/acme-debugbar.php | 12 ++ tests/e2e/specs/debugbar.spec.ts | 48 ++++- 34 files changed, 2463 insertions(+), 37 deletions(-) create mode 100644 resources/doctor-widget.js create mode 100644 src/Collectors/DoctorCollector.php create mode 100644 src/Collectors/WpAssetsCollector.php create mode 100644 src/Collectors/WpBlocksCollector.php create mode 100644 src/Collectors/WpCacheCollector.php create mode 100644 src/Collectors/WpCapabilitiesCollector.php create mode 100644 src/Collectors/WpHttpCollector.php create mode 100644 src/Collectors/WpLanguagesCollector.php create mode 100644 src/Http/DoctorController.php create mode 100644 src/Recording/AsyncRecorder.php create mode 100644 src/Recording/BlockRecorder.php create mode 100644 src/Recording/CacheRecorder.php create mode 100644 src/Recording/CapabilityRecorder.php create mode 100644 src/Recording/HttpRecorder.php create mode 100644 src/Recording/LanguageRecorder.php create mode 100644 src/Recording/QueryTracer.php create mode 100644 src/Support/Components.php create mode 100644 tests/Feature/DoctorTest.php create mode 100644 tests/Unit/Recording/QueryTracerTest.php create mode 100644 tests/Unit/Recording/RecordersTest.php create mode 100644 tests/Unit/Support/ComponentsTest.php diff --git a/README.md b/README.md index 0286e3f..037efbf 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,22 @@ It brings `fruitcake/laravel-debugbar` with it. Nothing runs unless Laravel Debu | Tab | Origin | Shows | | --- | --- | --- | -| Pollora | Pollora | What answered (`Route::wp()`, the template hierarchy and its view, a Laravel route, or WordPress alone), versions, discovery, modules, theme, async actions | +| Pollora | Pollora | What answered (`Route::wp()`, the template hierarchy and its view, a Laravel route, or WordPress alone), versions, discovery, modules, theme, async actions registered and queued, WordPress constants and drop-ins | +| Doctor | Pollora | A **Run doctor** button: `pollora:doctor`'s web checks on demand, errors first | | WP Request | WordPress | Rewrite rule, query vars, queried object, main query, true conditionals, template and hierarchy candidates | -| WP Queries | WordPress | `$wpdb` queries with time, caller, duplicates, slow ones and the main query (turns `SAVEQUERIES` on) | +| WP Queries | WordPress | `$wpdb` queries with time, full backtrace, rows, errors, duplicates, slow ones and the main query, grouped by component (core, plugin, theme…) | | WP Hooks | WordPress | Hooks that ran, their callbacks, and those Pollora registered | +| WP HTTP | WordPress | `wp_remote_*` calls: result, time, transport, who made them | +| WP Cache | WordPress | Object cache hits and misses, transients set, OPcache | +| WP Capabilities | WordPress | `current_user_can()` checks, each distinct check once with its count | +| WP Blocks | WordPress | Blocks rendered by type with their time, Pollora's Blade blocks, block bindings | +| WP Assets | WordPress | Scripts, styles and script modules, header or footer, missing dependencies, Vite builds | +| WP Languages | WordPress | Locale and the translation files looked for | | Timeline | WordPress | `muplugins_loaded` to `shutdown`, beside Debugbar's own measures | -REST and admin-ajax requests end with `exit`, which Laravel Debugbar never sees: this package stores them and sends the `phpdebugbar-id` header, so they appear in the bar's request list of the page that made the call. +REST and admin-ajax requests end with `exit`, which Laravel Debugbar never sees: this package stores them and sends the `phpdebugbar-id` header, so they appear in the bar's request list of the page that made the call. A `wp_redirect()` keeps its request for the page it leads to. + +WordPress queries are traced through core's `log_query_custom_data` and `query` filters, so this works with any `db.php` drop-in, Pollora's included. Configuration: `php artisan vendor:publish --tag=debugbar-pollora-config`. diff --git a/config/debugbar-pollora.php b/config/debugbar-pollora.php index 51b2371..3c88cba 100644 --- a/config/debugbar-pollora.php +++ b/config/debugbar-pollora.php @@ -27,6 +27,13 @@ | wp_queries $wpdb queries (turns SAVEQUERIES on) | wp_hooks Hooks that ran and the callbacks Pollora registered | wp_timeline WordPress phases on Debugbar's timeline + | wp_http HTTP calls made through wp_remote_* + | wp_cache Object cache, transients set, OPcache + | wp_capabilities current_user_can() checks, aggregated + | wp_blocks Blocks rendered and block bindings resolved + | wp_assets Scripts, styles, script modules and Vite containers + | wp_languages Locale and translation files + | doctor A button that runs pollora:doctor's web checks on demand | bridges pollora/debugbar/* and Query Monitor's qm/* actions | */ @@ -37,6 +44,13 @@ 'wp_queries' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_QUERIES', true), 'wp_hooks' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_HOOKS', true), 'wp_timeline' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_TIMELINE', true), + 'wp_http' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_HTTP', true), + 'wp_cache' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_CACHE', true), + 'wp_capabilities' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_CAPABILITIES', true), + 'wp_blocks' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_BLOCKS', true), + 'wp_assets' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_ASSETS', true), + 'wp_languages' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_LANGUAGES', true), + 'doctor' => env('DEBUGBAR_POLLORA_COLLECTORS_DOCTOR', true), 'bridges' => env('DEBUGBAR_POLLORA_COLLECTORS_BRIDGES', true), ], @@ -49,6 +63,13 @@ 'soft_limit' => (int) env('DEBUGBAR_POLLORA_WP_QUERIES_SOFT_LIMIT', 100), // Past this many queries, the rest are left out 'hard_limit' => (int) env('DEBUGBAR_POLLORA_WP_QUERIES_HARD_LIMIT', 500), + // Full backtrace, error, rows and component for each query + 'trace' => env('DEBUGBAR_POLLORA_WP_QUERIES_TRACE', true), + ], + + 'wp_capabilities' => [ + // Who asked: a backtrace per distinct check + 'backtrace' => env('DEBUGBAR_POLLORA_WP_CAPABILITIES_BACKTRACE', false), ], 'wp_hooks' => [ diff --git a/resources/doctor-widget.js b/resources/doctor-widget.js new file mode 100644 index 0000000..94aae99 --- /dev/null +++ b/resources/doctor-widget.js @@ -0,0 +1,86 @@ +/* + * The Doctor tab: a button that runs pollora:doctor's web checks on demand + * and lists what they found, errors first. + */ +(function () { + if (! window.PhpDebugBar || PhpDebugBar.Widgets.PolloraDoctorWidget) { + return; + } + + const csscls = PhpDebugBar.utils.makecsscls('phpdebugbar-widgets-'); + + class PolloraDoctorWidget extends PhpDebugBar.Widget { + get className() { + return csscls('pollora-doctor'); + } + + render() { + const toolbar = document.createElement('div'); + toolbar.style.padding = '8px'; + + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = 'Run doctor'; + + const status = document.createElement('span'); + status.style.marginLeft = '8px'; + status.textContent = 'Runs pollora:doctor\'s web checks for this site.'; + + const table = document.createElement('table'); + table.classList.add(csscls('tablevar')); + table.style.width = '100%'; + + toolbar.append(button, status); + this.el.append(toolbar, table); + + button.addEventListener('click', async () => { + const url = this.get('data')?.url; + + if (! url) { + return; + } + + button.disabled = true; + status.textContent = 'Running…'; + table.innerHTML = ''; + + try { + const response = await fetch(url, { headers: { Accept: 'application/json' }, credentials: 'same-origin' }); + const body = await response.json(); + + if (! response.ok) { + throw new Error(body.message || response.status); + } + + const counts = {}; + + for (const check of body.checks) { + counts[check.status] = (counts[check.status] || 0) + 1; + + const row = document.createElement('tr'); + row.classList.add(csscls('item')); + + const details = (check.details || []).join(' · '); + + for (const value of [check.status, check.label, [check.summary, details, check.fix ? 'Fix: ' + check.fix : ''].filter(Boolean).join(' — ')]) { + const cell = document.createElement('td'); + cell.classList.add(csscls('value')); + cell.textContent = value; + row.append(cell); + } + + table.append(row); + } + + status.textContent = Object.entries(counts).map(([name, count]) => count + ' ' + name).join(', ') || 'No web checks.'; + } catch (error) { + status.textContent = 'The doctor could not run: ' + error.message; + } finally { + button.disabled = false; + } + }); + } + } + + PhpDebugBar.Widgets.PolloraDoctorWidget = PolloraDoctorWidget; +})(); diff --git a/src/Collector.php b/src/Collector.php index e07d535..a59bf7a 100644 --- a/src/Collector.php +++ b/src/Collector.php @@ -134,7 +134,7 @@ public function getWidgets(): array } /** - * @return array + * @return array */ public function getAssets(): array { @@ -150,6 +150,21 @@ public function getAssets(): array public function reset(): void {} + /** + * A path relative to the project, so the bar names a file someone can open. + */ + protected function relativePath(string $path): string + { + try { + $base = function_exists('base_path') ? rtrim(base_path(), '/').'/' : ''; + } catch (\Throwable) { + // No application to ask: show the path as it is + return $path; + } + + return $base !== '' && str_starts_with($path, $base) ? substr($path, strlen($base)) : $path; + } + /** * What the tab shows, in the shape its widget takes. * diff --git a/src/CollectorRegistrar.php b/src/CollectorRegistrar.php index e5fb241..ca3fe36 100644 --- a/src/CollectorRegistrar.php +++ b/src/CollectorRegistrar.php @@ -8,13 +8,28 @@ use DebugBar\DataCollector\TimeDataCollector; use Fruitcake\LaravelDebugbar\LaravelDebugbar; use Illuminate\Contracts\Container\Container; +use Pollora\Debugbar\Collectors\DoctorCollector; use Pollora\Debugbar\Collectors\PolloraCollector; +use Pollora\Debugbar\Collectors\WpAssetsCollector; +use Pollora\Debugbar\Collectors\WpBlocksCollector; +use Pollora\Debugbar\Collectors\WpCacheCollector; +use Pollora\Debugbar\Collectors\WpCapabilitiesCollector; use Pollora\Debugbar\Collectors\WpHooksCollector; +use Pollora\Debugbar\Collectors\WpHttpCollector; +use Pollora\Debugbar\Collectors\WpLanguagesCollector; use Pollora\Debugbar\Collectors\WpQueriesCollector; use Pollora\Debugbar\Collectors\WpRequestCollector; use Pollora\Debugbar\Collectors\WpTimelineCollector; use Pollora\Debugbar\Contracts\SectionProvider; +use Pollora\Debugbar\Recording\AsyncRecorder; +use Pollora\Debugbar\Recording\BlockRecorder; +use Pollora\Debugbar\Recording\CacheRecorder; +use Pollora\Debugbar\Recording\CapabilityRecorder; +use Pollora\Debugbar\Recording\HttpRecorder; +use Pollora\Debugbar\Recording\LanguageRecorder; +use Pollora\Debugbar\Recording\QueryTracer; use Pollora\Debugbar\Recording\RequestRecorder; +use Pollora\Debugbar\Support\Components; use Pollora\Hook\Domain\Contract\Action; use Pollora\Hook\Domain\Contract\Filter; @@ -98,7 +113,7 @@ private function builtIns(LaravelDebugbar $debugbar): array $collectors = []; if ($on('pollora')) { - $collectors[] = new PolloraCollector($this->container); + $collectors[] = new PolloraCollector($this->container, $this->container->make(AsyncRecorder::class)); } if ($on('wp_request')) { @@ -112,6 +127,8 @@ private function builtIns(LaravelDebugbar $debugbar): array is_numeric($threshold) ? (float) $threshold : null, (int) $config->get('debugbar-pollora.options.wp_queries.soft_limit', 100), (int) $config->get('debugbar-pollora.options.wp_queries.hard_limit', 500), + $this->container->make(Components::class), + $config->get('debugbar-pollora.options.wp_queries.trace', true) ? $this->container->make(QueryTracer::class) : null, ); } @@ -119,6 +136,36 @@ private function builtIns(LaravelDebugbar $debugbar): array $collectors[] = new WpHooksCollector($this->recorder, $this->polloraHookServices()); } + $components = $this->container->make(Components::class); + + if ($on('wp_http')) { + $collectors[] = new WpHttpCollector($this->container->make(HttpRecorder::class), $components); + } + + if ($on('wp_cache')) { + $collectors[] = new WpCacheCollector($this->container->make(CacheRecorder::class), $components); + } + + if ($on('wp_capabilities')) { + $collectors[] = new WpCapabilitiesCollector($this->container->make(CapabilityRecorder::class), $components); + } + + if ($on('wp_blocks')) { + $collectors[] = new WpBlocksCollector($this->container->make(BlockRecorder::class)); + } + + if ($on('wp_assets')) { + $collectors[] = new WpAssetsCollector($this->container); + } + + if ($on('wp_languages')) { + $collectors[] = new WpLanguagesCollector($this->container->make(LanguageRecorder::class)); + } + + if ($on('doctor')) { + $collectors[] = new DoctorCollector('/'.trim((string) $config->get('debugbar.route_prefix', '_debugbar'), '/').'/pollora/doctor'); + } + if ($on('wp_timeline') && $debugbar->hasCollector('time')) { $time = $debugbar->getCollector('time'); diff --git a/src/Collectors/DoctorCollector.php b/src/Collectors/DoctorCollector.php new file mode 100644 index 0000000..458afcc --- /dev/null +++ b/src/Collectors/DoctorCollector.php @@ -0,0 +1,82 @@ + $this->data(), 'count' => null]; + } + + /** + * @return array> + */ + public function getWidgets(): array + { + $widgets = parent::getWidgets(); + $widgets['pollora_doctor']['widget'] = 'PhpDebugBar.Widgets.PolloraDoctorWidget'; + + return $widgets; + } + + /** + * @return array{inline_js: array} + */ + public function getAssets(): array + { + return ['inline_js' => ['pollora-doctor-widget' => (string) file_get_contents(__DIR__.'/../../resources/doctor-widget.js')]]; + } + + /** + * @return array{url: string} + */ + protected function data(): array + { + return ['url' => $this->url]; + } +} diff --git a/src/Collectors/PolloraCollector.php b/src/Collectors/PolloraCollector.php index 604e957..8cce2d6 100644 --- a/src/Collectors/PolloraCollector.php +++ b/src/Collectors/PolloraCollector.php @@ -10,6 +10,7 @@ use Illuminate\Routing\Route; use Pollora\Debugbar\Collector; use Pollora\Debugbar\Origin; +use Pollora\Debugbar\Recording\AsyncRecorder; use Pollora\Discovery\Application\Services\DiscoveryManager; use Pollora\Hook\Infrastructure\Services\AsyncInspector; use Pollora\Modules\Application\Services\ModuleStates; @@ -29,6 +30,7 @@ final class PolloraCollector extends Collector { public function __construct( private readonly Container $container, + private readonly ?AsyncRecorder $async = null, ) {} public function getName(): string @@ -64,6 +66,8 @@ protected function data(): array 'Modules' => $this->safely($this->modules(...)), 'Theme' => $this->safely($this->theme(...)), 'Async actions' => $this->safely($this->asyncActions(...)), + 'Async dispatched' => $this->safely($this->asyncDispatched(...)), + 'WordPress' => $this->safely($this->wordpress(...)), ]; } @@ -110,7 +114,7 @@ private function template(): ?array } return [ - 'file' => $this->relative($resolution->template), + 'file' => $this->relativePath($resolution->template), 'view' => $resolution->view, 'condition' => $resolution->condition, 'index fallback' => $resolution->usedIndexFallback, @@ -157,7 +161,7 @@ private function discovery(): ?array 'time' => sprintf('%.1f ms', array_sum(array_column($scans, 'milliseconds'))), 'locations' => array_map(fn (array $scan): string => sprintf( '%s · %s · %d structures · %.1f ms', - $this->relative($scan['path']), + $this->relativePath($scan['path']), $scan['source'], $scan['structures'], $scan['milliseconds'], @@ -225,6 +229,51 @@ private function asyncActions(): ?array ) ?: null; } + /** + * What this request queued, with `pollora/hook` 1.5+. + * + * @return list|null + */ + private function asyncDispatched(): ?array + { + $dispatched = $this->async?->dispatched() ?? []; + + return array_map( + static fn (array $item): string => sprintf('%s → %s · %s%s', $item['hook'], $item['handler'], $item['driver'] !== '' ? $item['driver'] : 'default driver', $item['delay'] > 0 ? " · in {$item['delay']} s" : ''), + $dispatched, + ) ?: null; + } + + /** + * The constants and drop-ins that change how WordPress behaves. + * + * @return array + */ + private function wordpress(): array + { + $constants = []; + + foreach (['WP_DEBUG', 'WP_DEBUG_LOG', 'WP_DEBUG_DISPLAY', 'SCRIPT_DEBUG', 'SAVEQUERIES', 'WP_CACHE', 'DISABLE_WP_CRON', 'CONCATENATE_SCRIPTS'] as $constant) { + $constants[$constant] = defined($constant) ? constant($constant) : 'undefined'; + } + + $dropIns = []; + + if (defined('WP_CONTENT_DIR')) { + foreach (['db.php', 'object-cache.php', 'advanced-cache.php', 'maintenance.php', 'sunrise.php'] as $dropIn) { + if (is_file(WP_CONTENT_DIR.'/'.$dropIn)) { + $dropIns[] = $dropIn; + } + } + } + + return [ + 'environment' => function_exists('wp_get_environment_type') ? wp_get_environment_type() : null, + 'constants' => $constants, + 'drop-ins' => $dropIns === [] ? 'none' : implode(', ', $dropIns), + ]; + } + private function resolution(): ?TemplateResolution { return $this->container->bound(AnsweringTemplate::class) @@ -243,11 +292,4 @@ private function safely(\Closure $read): mixed return 'unavailable: '.$throwable->getMessage(); } } - - private function relative(string $path): string - { - $base = function_exists('base_path') ? rtrim(base_path(), '/').'/' : ''; - - return $base !== '' && str_starts_with($path, $base) ? substr($path, strlen($base)) : $path; - } } diff --git a/src/Collectors/WpAssetsCollector.php b/src/Collectors/WpAssetsCollector.php new file mode 100644 index 0000000..3d24ad5 --- /dev/null +++ b/src/Collectors/WpAssetsCollector.php @@ -0,0 +1,186 @@ + + */ + public function columns(): array + { + return ['printed' => 'Printed', 'source' => 'Source', 'dependencies' => 'Dependencies', 'note' => 'Note']; + } + + /** + * @return array + */ + protected function data(): array + { + global $wp_scripts, $wp_styles; + + return [ + ...$this->vite(), + ...$this->dependencies('script', $wp_scripts), + ...$this->dependencies('style', $wp_styles), + ...$this->modules(), + ]; + } + + /** + * @return array + */ + private function dependencies(string $kind, mixed $dependencies): array + { + if (! $dependencies instanceof \WP_Dependencies) { + return []; + } + + $rows = []; + $handles = array_unique([...$dependencies->queue, ...$dependencies->done]); + + foreach ($handles as $handle) { + $asset = $dependencies->registered[$handle] ?? null; + $deps = $asset instanceof \_WP_Dependency ? $asset->deps : []; + $missing = array_filter($deps, static fn (string $dependency): bool => ! isset($dependencies->registered[$dependency])); + $printed = in_array($handle, $dependencies->done, true); + + $rows["{$kind}: {$handle}"] = [ + 'printed' => match (true) { + ! $printed => 'no', + $kind === 'script' && (int) $dependencies->get_data($handle, 'group') === 1 => 'footer', + default => 'header', + }, + 'source' => $asset instanceof \_WP_Dependency && is_string($asset->src) ? $this->shortUrl($asset->src) : '', + 'dependencies' => implode(', ', $deps), + 'note' => $missing !== [] ? 'missing: '.implode(', ', $missing) : '', + ]; + } + + return $rows; + } + + /** + * @return array + */ + private function modules(): array + { + if (! function_exists('wp_script_modules')) { + return []; + } + + $modules = wp_script_modules(); + + if (! method_exists($modules, 'get_queue')) { + return []; + } + + $rows = []; + + foreach ($modules->get_queue() as $id) { + $module = method_exists($modules, 'get_registered') ? $modules->get_registered($id) : null; + + $rows["module: {$id}"] = $module === null + ? ['printed' => '', 'source' => '', 'dependencies' => '', 'note' => 'not registered'] + : [ + 'printed' => $module['in_footer'] ? 'footer' : 'header', + 'source' => $this->shortUrl($module['src']), + 'dependencies' => implode(', ', array_column($module['dependencies'], 'id')), + 'note' => '', + ]; + } + + return $rows; + } + + /** + * Pollora's Vite containers: dev server or build. + * + * @return array + */ + private function vite(): array + { + if (! $this->container->bound(AssetManager::class)) { + return []; + } + + $manager = $this->container->make(AssetManager::class); + + if (! method_exists($manager, 'containers')) { + return []; + } + + $rows = []; + + foreach ($manager->containers() as $name => $container) { + $hot = is_file($container->getHotFile()); + $manifest = function_exists('public_path') ? public_path(trim($container->getBuildDirectory(), '/').'/'.$container->getManifestPath()) : ''; + + $rows["vite: {$name}"] = [ + 'printed' => '', + 'source' => $hot ? trim((string) file_get_contents($container->getHotFile())) : $this->shortUrl($container->getBuildDirectory()), + 'dependencies' => '', + 'note' => $hot ? 'dev server (hot file)' : ($manifest !== '' && is_file($manifest) ? 'build (manifest)' : 'no manifest: nothing built here'), + ]; + } + + return $rows; + } + + private function shortUrl(string $url): string + { + $home = function_exists('home_url') ? rtrim(home_url(), '/') : ''; + + return $home !== '' && str_starts_with($url, $home) ? substr($url, strlen($home)) : $url; + } +} diff --git a/src/Collectors/WpBlocksCollector.php b/src/Collectors/WpBlocksCollector.php new file mode 100644 index 0000000..d18ebba --- /dev/null +++ b/src/Collectors/WpBlocksCollector.php @@ -0,0 +1,138 @@ + + */ + public function columns(): array + { + return ['count' => 'Count', 'time' => 'Time', 'kind' => 'Kind', 'detail' => 'Detail']; + } + + /** + * @return array + */ + protected function data(): array + { + $formatter = $this->getDataFormatter(); + $rows = []; + + foreach ($this->recorder->blocks() as $name => $block) { + $rows[$name] = [ + 'count' => $block['count'], + 'time' => $formatter->formatDuration($block['milliseconds'] / 1000), + 'kind' => $this->kind($name), + 'detail' => $block['maxDepth'] > 0 ? 'nested up to '.$block['maxDepth'].' deep' : '', + ]; + } + + $bindings = []; + + foreach ($this->recorder->bindings() as $binding) { + $key = 'binding '.$binding->source.($binding->field !== '' ? '.'.$binding->field : '').' → '.$binding->attribute; + $row = $bindings[$key] ?? ['count' => 0, 'milliseconds' => 0.0, 'cached' => 0, 'empty' => 0, 'posts' => []]; + $row['count']++; + $row['milliseconds'] += $binding->milliseconds; + $row['cached'] += $binding->cached ? 1 : 0; + $row['empty'] += $binding->hasValue ? 0 : 1; + + if ($binding->postId !== null) { + $row['posts'][$binding->postId] = true; + } + + $bindings[$key] = $row; + } + + foreach ($bindings as $key => $row) { + $rows[$key] = [ + 'count' => $row['count'], + 'time' => $formatter->formatDuration($row['milliseconds'] / 1000), + 'kind' => 'binding', + 'detail' => implode(', ', array_filter([ + $row['posts'] !== [] ? 'post '.implode(', ', array_keys($row['posts'])) : null, + $row['cached'] > 0 ? $row['cached'].' from cache' : null, + $row['empty'] > 0 ? $row['empty'].' without value' : null, + ])), + ]; + } + + return $rows; + } + + /** + * Core, a Pollora Blade block, or another plugin's. + */ + private function kind(string $name): string + { + if (str_starts_with($name, 'core/')) { + return 'core'; + } + + if (! class_exists(\WP_Block_Type_Registry::class)) { + return 'block'; + } + + $type = \WP_Block_Type_Registry::get_instance()->get_registered($name); + $render = $type instanceof \WP_Block_Type ? $type->render_callback : null; + + if ($render instanceof \Closure) { + $scope = (new \ReflectionFunction($render))->getClosureScopeClass()?->getName() ?? ''; + + if (str_starts_with($scope, 'Pollora\\')) { + return 'Blade (Pollora)'; + } + } + + return $render !== null ? 'dynamic' : 'static'; + } +} diff --git a/src/Collectors/WpCacheCollector.php b/src/Collectors/WpCacheCollector.php new file mode 100644 index 0000000..b88a819 --- /dev/null +++ b/src/Collectors/WpCacheCollector.php @@ -0,0 +1,102 @@ + + */ + protected function data(): array + { + global $wp_object_cache; + + $hits = is_object($wp_object_cache) && isset($wp_object_cache->cache_hits) ? (int) $wp_object_cache->cache_hits : null; + $misses = is_object($wp_object_cache) && isset($wp_object_cache->cache_misses) ? (int) $wp_object_cache->cache_misses : null; + + $transients = []; + + foreach ($this->recorder->transients() as $transient) { + $transients[] = sprintf( + '%s%s · %s · %d bytes · %s', + $transient['network'] ? 'site: ' : '', + $transient['name'], + $transient['expiration'] > 0 ? "expires in {$transient['expiration']} s" : 'no expiration', + $transient['size'], + $this->components->ofTrace($transient['frames']), + ); + } + + return [ + 'Object cache' => match (true) { + ! is_object($wp_object_cache) => null, + function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache() => 'persistent ('.$wp_object_cache::class.')', + default => 'in memory for this request only ('.$wp_object_cache::class.')', + }, + 'Hits' => $hits, + 'Misses' => $misses, + 'Hit ratio' => $hits !== null && $misses !== null && $hits + $misses > 0 ? sprintf('%.1f %%', $hits / ($hits + $misses) * 100) : null, + 'Transients set' => $transients === [] ? 'none' : $transients, + 'OPcache' => $this->opcache(), + ]; + } + + private function opcache(): string + { + if (! function_exists('opcache_get_status')) { + return 'not installed'; + } + + $status = @opcache_get_status(false); + + if (! is_array($status) || ! ($status['opcache_enabled'] ?? false)) { + return 'off'; + } + + $statistics = $status['opcache_statistics'] ?? []; + + return sprintf('on, %.1f %% hits', (float) ($statistics['opcache_hit_rate'] ?? 0)); + } +} diff --git a/src/Collectors/WpCapabilitiesCollector.php b/src/Collectors/WpCapabilitiesCollector.php new file mode 100644 index 0000000..698b1b1 --- /dev/null +++ b/src/Collectors/WpCapabilitiesCollector.php @@ -0,0 +1,88 @@ + + */ + public function columns(): array + { + return ['result' => 'Result', 'user' => 'User', 'count' => 'Checks', 'component' => 'From']; + } + + /** + * @return array + */ + protected function data(): array + { + $rows = []; + + foreach ($this->recorder->checks() as $check) { + $label = $check['capability'].($check['arguments'] !== '' ? "({$check['arguments']})" : ''); + $key = $label; + + // The same capability can be granted to one user and refused to another + while (isset($rows[$key])) { + $key .= ' '; + } + + $rows[$key] = [ + 'result' => $check['granted'] ? 'granted' : 'refused', + 'user' => $check['user'] > 0 ? '#'.$check['user'] : 'visitor', + 'count' => $check['count'], + 'component' => $check['frames'] !== [] ? $this->components->ofTrace($check['frames']) : '—', + ]; + } + + return $rows; + } +} diff --git a/src/Collectors/WpHttpCollector.php b/src/Collectors/WpHttpCollector.php new file mode 100644 index 0000000..7cdaf4e --- /dev/null +++ b/src/Collectors/WpHttpCollector.php @@ -0,0 +1,86 @@ + + */ + public function columns(): array + { + return ['result' => 'Result', 'time' => 'Time', 'transport' => 'Transport', 'component' => 'From']; + } + + /** + * @return array + */ + protected function data(): array + { + $rows = []; + + foreach ($this->recorder->calls() as $index => $call) { + $result = match (true) { + $call['error'] !== null => 'error: '.$call['error'], + $call['end'] === null => 'no response recorded', + $call['shortCircuited'] => ($call['status'] ?? 'answered').' (answered by pre_http_request)', + default => (string) ($call['status'] ?? '—'), + }; + + $rows[sprintf('%d. %s %s', $index + 1, $call['method'], $call['url'])] = [ + 'result' => $result, + 'time' => $call['end'] !== null ? $this->getDataFormatter()->formatDuration($call['end'] - $call['start']) : '—', + 'transport' => $call['transport'] ?? '—', + 'component' => $this->components->ofTrace($call['frames']), + ]; + } + + return $rows; + } +} diff --git a/src/Collectors/WpLanguagesCollector.php b/src/Collectors/WpLanguagesCollector.php new file mode 100644 index 0000000..5c77e8b --- /dev/null +++ b/src/Collectors/WpLanguagesCollector.php @@ -0,0 +1,80 @@ + + */ + public function columns(): array + { + return ['file' => 'File', 'found' => 'Found', 'kind' => 'Kind']; + } + + /** + * @return array + */ + protected function data(): array + { + $rows = []; + + if (function_exists('determine_locale')) { + $rows['locale'] = ['file' => determine_locale().(function_exists('get_user_locale') ? ' (user: '.get_user_locale().')' : ''), 'found' => '', 'kind' => '']; + } + + foreach ($this->recorder->files() as $index => $file) { + $rows[sprintf('%d. %s', $index + 1, $file['domain'])] = [ + 'file' => $this->relativePath($file['file']), + 'found' => $file['found'] ? 'yes' : 'no', + 'kind' => $file['kind'], + ]; + } + + return $rows; + } +} diff --git a/src/Collectors/WpQueriesCollector.php b/src/Collectors/WpQueriesCollector.php index 60e5d26..f2ad55e 100644 --- a/src/Collectors/WpQueriesCollector.php +++ b/src/Collectors/WpQueriesCollector.php @@ -6,6 +6,8 @@ use Pollora\Debugbar\Collector; use Pollora\Debugbar\Origin; +use Pollora\Debugbar\Recording\QueryTracer; +use Pollora\Debugbar\Support\Components; use Pollora\Debugbar\Widget; /** @@ -14,7 +16,9 @@ * Laravel Debugbar only sees Laravel's PDO connection; WordPress talks to the * same database through its own mysqli one. With `SAVEQUERIES` on, `$wpdb` * keeps each query with its time and a caller string, read here once, at the - * end of the request. + * end of the request. With the query tracer on, each query also carries its + * full backtrace, error and row count, and is attributed to a component — + * shown as the SQL widget's connection, so its filter links group by it. */ final class WpQueriesCollector extends Collector { @@ -27,6 +31,8 @@ public function __construct( private readonly ?float $slowThreshold = null, private readonly int $softLimit = 100, private readonly int $hardLimit = 500, + private readonly ?Components $components = null, + private readonly ?QueryTracer $tracer = null, ) {} public function getName(): string @@ -71,9 +77,13 @@ protected function data(): array ? trim($wp_the_query->request) : null; + $this->tracer?->completePrevious(); + $queries = is_object($wpdb) && isset($wpdb->queries) && is_array($wpdb->queries) ? $wpdb->queries : $queries; + $formatter = $this->getDataFormatter(); $statements = []; $total = 0.0; + $failed = 0; foreach (array_values($queries) as $index => $query) { if ($index >= $this->hardLimit) { @@ -82,9 +92,22 @@ protected function data(): array $sql = trim((string) ($query[0] ?? '')); $duration = (float) ($query[1] ?? 0); - $frames = $index < $this->softLimit ? $this->frames((string) ($query[2] ?? '')) : []; + $traced = is_array($query[4] ?? null) && is_array($query[4][QueryTracer::KEY] ?? null) ? $query[4][QueryTracer::KEY] : null; $total += $duration; + if ($traced !== null && ($traced['frames'] ?? []) !== []) { + $frames = array_map(fn (array $frame): string => sprintf('%s — %s:%d', $frame['call'], $this->relativePath($frame['file']), $frame['line']), $traced['frames']); + $component = $this->components?->ofTrace($traced['frames']); + $source = $traced['frames'][0]['call']; + } else { + $frames = $index < $this->softLimit ? $this->frames((string) ($query[2] ?? '')) : []; + $component = null; + $source = $frames[0] ?? ''; + } + + $error = is_string($traced['error'] ?? null) ? $traced['error'] : null; + $failed += $error !== null ? 1 : 0; + $statements[] = [ 'sql' => $sql, 'type' => 'query', @@ -92,15 +115,15 @@ protected function data(): array 'duration' => $duration, 'duration_str' => $formatter->formatDuration($duration), 'memory_str' => '', - 'row_count' => null, - 'is_success' => true, + 'row_count' => is_int($traced['rows'] ?? null) ? $traced['rows'] : null, + 'is_success' => $error === null, 'error_code' => null, - 'error_message' => null, + 'error_message' => $error, 'backtrace' => $frames, - 'filename' => ($sql === $mainQuery ? 'main query · ' : '').($frames[0] ?? ''), + 'filename' => ($sql === $mainQuery ? 'main query · ' : '').$source, 'xdebug_link' => null, 'slow' => $this->slowThreshold !== null && $duration * 1000 >= $this->slowThreshold, - 'connection' => 'wpdb', + 'connection' => $component !== null ? "wpdb · {$component}" : 'wpdb', ]; } @@ -116,7 +139,7 @@ protected function data(): array return [ 'nb_statements' => count($statements), 'nb_excluded_statements' => max(0, count($queries) - count($statements)), - 'nb_failed_statements' => 0, + 'nb_failed_statements' => $failed, 'accumulated_duration' => $total, 'accumulated_duration_str' => $formatter->formatDuration($total), 'memory_usage_str' => '', diff --git a/src/Collectors/WpRequestCollector.php b/src/Collectors/WpRequestCollector.php index 034c093..347bfe0 100644 --- a/src/Collectors/WpRequestCollector.php +++ b/src/Collectors/WpRequestCollector.php @@ -93,7 +93,7 @@ protected function data(): array $data['Conditionals'] = implode(', ', $this->trueConditionals()); } - $data['Template'] = $this->recorder->template() !== null ? $this->relative($this->recorder->template()) : null; + $data['Template'] = $this->recorder->template() !== null ? $this->relativePath($this->recorder->template()) : null; $data['Template hierarchy'] = $this->recorder->hierarchies() !== [] ? $this->recorder->hierarchies() : null; return $data; @@ -131,11 +131,4 @@ private function queriedObject(): ?string default => null, }; } - - private function relative(string $path): string - { - $base = function_exists('base_path') ? rtrim(base_path(), '/').'/' : ''; - - return $base !== '' && str_starts_with($path, $base) ? substr($path, strlen($base)) : $path; - } } diff --git a/src/DebugbarServiceProvider.php b/src/DebugbarServiceProvider.php index fef541a..682c49f 100644 --- a/src/DebugbarServiceProvider.php +++ b/src/DebugbarServiceProvider.php @@ -5,10 +5,21 @@ namespace Pollora\Debugbar; use Fruitcake\LaravelDebugbar\LaravelDebugbar; +use Fruitcake\LaravelDebugbar\Middleware\DebugbarEnabled; use Illuminate\Support\ServiceProvider; +use Pollora\BlockBinding\Domain\Events\BindingResolved; use Pollora\Debugbar\Bridges\MessageBridge; +use Pollora\Debugbar\Http\DoctorController; use Pollora\Debugbar\Http\WordPressExitResponder; +use Pollora\Debugbar\Recording\AsyncRecorder; +use Pollora\Debugbar\Recording\BlockRecorder; +use Pollora\Debugbar\Recording\CacheRecorder; +use Pollora\Debugbar\Recording\CapabilityRecorder; +use Pollora\Debugbar\Recording\HttpRecorder; +use Pollora\Debugbar\Recording\LanguageRecorder; +use Pollora\Debugbar\Recording\QueryTracer; use Pollora\Debugbar\Recording\RequestRecorder; +use Pollora\Debugbar\Support\Components; use Pollora\WordPress\Events\WordPressBooting; /** @@ -27,6 +38,14 @@ public function register(): void $this->app->singleton(RequestRecorder::class); $this->app->singleton(CollectorRegistrar::class); + $this->app->singleton(Components::class, fn (): Components => new Components); + $this->app->singleton(QueryTracer::class, fn ($app): QueryTracer => new QueryTracer((int) $app->make('config')->get('debugbar-pollora.options.wp_queries.soft_limit', 100))); + $this->app->singleton(HttpRecorder::class); + $this->app->singleton(CacheRecorder::class); + $this->app->singleton(LanguageRecorder::class); + $this->app->singleton(CapabilityRecorder::class, fn ($app): CapabilityRecorder => new CapabilityRecorder((bool) $app->make('config')->get('debugbar-pollora.options.wp_capabilities.backtrace', false))); + $this->app->singleton(BlockRecorder::class); + $this->app->singleton(AsyncRecorder::class); if (! Activation::shouldRun($this->app)) { return; @@ -38,11 +57,33 @@ public function register(): void define('SAVEQUERIES', true); } - $this->app->make('events')->listen(WordPressBooting::class, function () use ($config): void { + $on = static fn (string $collector): bool => (bool) $config->get("debugbar-pollora.collectors.{$collector}", true); + + if ($on('wp_blocks') && class_exists(BindingResolved::class)) { + $this->app->make('events')->listen(BindingResolved::class, function (BindingResolved $binding): void { + $this->app->make(BlockRecorder::class)->recordBinding($binding); + }); + } + + $this->app->make('events')->listen(WordPressBooting::class, function () use ($config, $on): void { $this->app->make(RequestRecorder::class)->install( countAllHooks: (bool) $config->get('debugbar-pollora.options.wp_hooks.count_filters', false), ); + $recorders = [ + QueryTracer::class => $on('wp_queries') && $config->get('debugbar-pollora.options.wp_queries.trace', true), + HttpRecorder::class => $on('wp_http'), + CacheRecorder::class => $on('wp_cache'), + LanguageRecorder::class => $on('wp_languages'), + CapabilityRecorder::class => $on('wp_capabilities'), + BlockRecorder::class => $on('wp_blocks'), + AsyncRecorder::class => $on('pollora'), + ]; + + foreach (array_keys(array_filter($recorders)) as $recorder) { + $this->app->make($recorder)->install(); + } + if ($config->get('debugbar-pollora.collectors.bridges', true)) { (new MessageBridge($this->collectingDebugbar(...)))->install( queryMonitor: (bool) $config->get('debugbar-pollora.options.bridges.query_monitor', true), @@ -51,7 +92,13 @@ public function register(): void (new WordPressExitResponder( $this->collectingDebugbar(...), - fn (LaravelDebugbar $debugbar) => $this->app->make(CollectorRegistrar::class)->register($debugbar), + function (LaravelDebugbar $debugbar): void { + // WordPress can exit before the booted callbacks that index + // route names have run, and Debugbar's collectors link to + // its own named routes + $this->app->make('router')->getRoutes()->refreshNameLookups(); + $this->app->make(CollectorRegistrar::class)->register($debugbar); + }, ))->install(); }); } @@ -66,6 +113,13 @@ public function boot(): void return; } + if ($this->app->make('config')->get('debugbar-pollora.collectors.doctor', true)) { + $this->app->make('router') + ->get(trim((string) $this->app->make('config')->get('debugbar.route_prefix', '_debugbar'), '/').'/pollora/doctor', DoctorController::class) + ->middleware([...(array) $this->app->make('config')->get('debugbar.route_middleware', []), DebugbarEnabled::class]) + ->name('debugbar.pollora.doctor'); + } + $this->app->booted(function (): void { $debugbar = $this->collectingDebugbar(); diff --git a/src/Http/DoctorController.php b/src/Http/DoctorController.php new file mode 100644 index 0000000..21413b5 --- /dev/null +++ b/src/Http/DoctorController.php @@ -0,0 +1,49 @@ +isStorageOpen($request)) { + return new JsonResponse(['message' => 'The doctor answers local and private addresses only, as Debugbar\'s stored requests do (debugbar.storage.open).'], 403); + } + + if (! class_exists(Doctor::class)) { + return new JsonResponse(['message' => 'pollora:doctor is not available in this version of the framework.'], 501); + } + + $order = ['error' => 0, 'warning' => 1, 'ok' => 2, 'skipped' => 3]; + + $checks = array_map(static fn (array $run): array => [ + 'id' => $run['check']->id(), + 'label' => $run['check']->label(), + 'status' => $run['result']->status->value, + 'summary' => $run['result']->summary, + 'details' => $run['result']->details, + 'fix' => $run['result']->fix, + ], $container->make(Doctor::class)->run(RunContext::Http)); + + usort($checks, static fn (array $a, array $b): int => $order[$a['status']] <=> $order[$b['status']]); + + return new JsonResponse(['checks' => $checks]); + } +} diff --git a/src/Http/WordPressExitResponder.php b/src/Http/WordPressExitResponder.php index 03529ff..0b4b6e5 100644 --- a/src/Http/WordPressExitResponder.php +++ b/src/Http/WordPressExitResponder.php @@ -5,6 +5,8 @@ namespace Pollora\Debugbar\Http; use Fruitcake\LaravelDebugbar\LaravelDebugbar; +use Fruitcake\LaravelDebugbar\LaravelHttpDriver; +use Symfony\Component\HttpFoundation\Response; /** * Keeps the requests WordPress answers and then exits from — REST and @@ -16,6 +18,11 @@ * about them. Here the request id goes out in the `phpdebugbar-id` header * before any output, and the data is collected and stored at `shutdown`. The * bar's fetch and XHR capture then lists the call like any other. + * + * `wp_redirect()` is followed by `exit` too. The request is stacked as + * Debugbar stacks a Laravel redirect, so the next page shows both: its HTTP + * driver can only set its cookie on a Laravel response, so it is given one, + * and the cookie is sent by hand. */ final class WordPressExitResponder { @@ -37,6 +44,52 @@ public function install(): void add_filter('rest_post_dispatch', $this->tagRestResponse(...), 10, 1); add_action('admin_init', $this->tagAjaxResponse(...), 0, 0); add_action('shutdown', $this->collect(...), 0, 0); + add_filter('wp_redirect', function (mixed $location, mixed $status): mixed { + if (is_string($location) && $location !== '') { + $this->stackRedirect($location, (int) $status); + } + + return $location; + }, PHP_INT_MAX, 2); + } + + /** + * Keep this request for the page the redirect leads to. + */ + public function stackRedirect(string $location, int $status): void + { + $debugbar = ($this->debugbar)(); + + if (! $debugbar instanceof LaravelDebugbar) { + return; + } + + $debugbar->addMessage(sprintf('wp_redirect() %d to %s', $status, $location), 'redirect'); + + $driver = $debugbar->getHttpDriver(); + + if (headers_sent() || ! $driver instanceof LaravelHttpDriver) { + return; + } + + $response = new Response; + + // A debugging tool must never break the redirect it watches + try { + ($this->prepare)($debugbar); + $driver->setResponse($response); + $debugbar->stackData(); + } catch (\Throwable $throwable) { + report($throwable); + + return; + } finally { + $driver->setResponse(null); + } + + foreach ($response->headers->getCookies() as $cookie) { + header('Set-Cookie: '.$cookie, false); + } } /** @@ -87,10 +140,14 @@ public function collect(): void return; } - ($this->prepare)($debugbar); + try { + ($this->prepare)($debugbar); - // getData() collects, and stores, only when nothing has been collected yet. - $debugbar->getData(); + // getData() collects, and stores, only when nothing has been collected yet. + $debugbar->getData(); + } catch (\Throwable $throwable) { + report($throwable); + } } private function storingDebugbar(): ?LaravelDebugbar diff --git a/src/Recording/AsyncRecorder.php b/src/Recording/AsyncRecorder.php new file mode 100644 index 0000000..c28d9b2 --- /dev/null +++ b/src/Recording/AsyncRecorder.php @@ -0,0 +1,52 @@ + + */ + private array $dispatched = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_action')) { + return; + } + + $this->installed = true; + + add_action('pollora/async/dispatched', function (mixed $payload, mixed $delay = 0): void { + if (is_object($payload)) { + $this->dispatched[] = [ + 'hook' => (string) ($payload->hook ?? ''), + 'handler' => is_string($payload->handler ?? null) ? $payload->handler : get_debug_type($payload->handler ?? null), + 'driver' => (string) ($payload->driver ?? ''), + 'delay' => (int) $delay, + ]; + } + }, 10, 2); + } + + /** + * @return list + */ + public function dispatched(): array + { + return $this->dispatched; + } + + public function reset(): void + { + $this->dispatched = []; + } +} diff --git a/src/Recording/BlockRecorder.php b/src/Recording/BlockRecorder.php new file mode 100644 index 0000000..4b02ede --- /dev/null +++ b/src/Recording/BlockRecorder.php @@ -0,0 +1,101 @@ + + */ + private array $open = []; + + /** + * @var array + */ + private array $blocks = []; + + /** + * @var list + */ + private array $bindings = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_filter')) { + return; + } + + $this->installed = true; + + add_filter('pre_render_block', function (mixed $pre, mixed $parsedBlock): mixed { + if ($pre === null && is_array($parsedBlock)) { + $this->open[] = ['name' => (string) ($parsedBlock['blockName'] ?? 'freeform'), 'start' => microtime(true)]; + } + + return $pre; + }, PHP_INT_MAX, 2); + + add_filter('render_block', function (mixed $content): mixed { + $this->close(); + + return $content; + }, PHP_INT_MIN, 1); + } + + public function recordBinding(BindingResolved $binding): void + { + $this->bindings[] = $binding; + } + + /** + * @return array + */ + public function blocks(): array + { + return $this->blocks; + } + + /** + * @return list + */ + public function bindings(): array + { + return $this->bindings; + } + + public function reset(): void + { + $this->open = []; + $this->blocks = []; + $this->bindings = []; + } + + private function close(): void + { + $render = array_pop($this->open); + + if ($render === null) { + return; + } + + $block = $this->blocks[$render['name']] ?? ['count' => 0, 'milliseconds' => 0.0, 'maxDepth' => 0]; + $block['count']++; + $block['milliseconds'] += (microtime(true) - $render['start']) * 1000; + $block['maxDepth'] = max($block['maxDepth'], count($this->open)); + + $this->blocks[$render['name']] = $block; + } +} diff --git a/src/Recording/CacheRecorder.php b/src/Recording/CacheRecorder.php new file mode 100644 index 0000000..fb71fc2 --- /dev/null +++ b/src/Recording/CacheRecorder.php @@ -0,0 +1,62 @@ +}> + */ + private array $transients = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_action')) { + return; + } + + $this->installed = true; + + // set_transient / set_site_transient replaced setted_* in WordPress 6.8 + add_action('set_transient', function (mixed $name, mixed $value, mixed $expiration): void { + $this->record((string) $name, $value, (int) $expiration, false); + }, PHP_INT_MAX, 3); + add_action('set_site_transient', function (mixed $name, mixed $value, mixed $expiration): void { + $this->record((string) $name, $value, (int) $expiration, true); + }, PHP_INT_MAX, 3); + } + + /** + * @return list}> + */ + public function transients(): array + { + return $this->transients; + } + + public function reset(): void + { + $this->transients = []; + } + + private function record(string $name, mixed $value, int $expiration, bool $network): void + { + $this->transients[] = [ + 'name' => $name, + 'network' => $network, + 'expiration' => $expiration, + 'size' => strlen(serialize($value)), + 'frames' => array_values(array_filter( + array_map(static fn (array $frame): array => ['file' => $frame['file'] ?? ''], debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 15)), + static fn (array $frame): bool => $frame['file'] !== '', + )), + ]; + } +} diff --git a/src/Recording/CapabilityRecorder.php b/src/Recording/CapabilityRecorder.php new file mode 100644 index 0000000..2d4838c --- /dev/null +++ b/src/Recording/CapabilityRecorder.php @@ -0,0 +1,103 @@ +}> + */ + private array $checks = []; + + public function __construct( + private readonly bool $backtrace = false, + ) {} + + public function install(): void + { + if ($this->installed || ! function_exists('add_filter')) { + return; + } + + $this->installed = true; + + add_filter('user_has_cap', function (mixed $allcaps, mixed $caps, mixed $args): mixed { + if (is_array($allcaps) && is_array($caps) && is_array($args)) { + $this->record($allcaps, $caps, $args); + } + + return $allcaps; + }, PHP_INT_MAX, 3); + } + + /** + * @return list}> + */ + public function checks(): array + { + return array_values($this->checks); + } + + public function reset(): void + { + $this->checks = []; + } + + /** + * @param array $allcaps What the user has + * @param array $caps The primitive capabilities the check maps to + * @param array $args [requested capability, user id, ...extra arguments] + */ + private function record(array $allcaps, array $caps, array $args): void + { + $capability = (string) ($args[0] ?? ''); + $user = (int) ($args[1] ?? 0); + $arguments = implode(', ', array_map( + static fn (mixed $argument): string => is_scalar($argument) ? (string) $argument : get_debug_type($argument), + array_slice($args, 2), + )); + + // Granted when every primitive capability it maps to is held, as WP_User::has_cap() decides + $granted = $caps !== []; + + foreach ($caps as $cap) { + if (empty($allcaps[$cap])) { + $granted = false; + + break; + } + } + + $key = $user.'|'.$capability.'|'.$arguments.'|'.($granted ? 1 : 0); + + if (isset($this->checks[$key])) { + $this->checks[$key]['count']++; + + return; + } + + $this->checks[$key] = [ + 'user' => $user, + 'capability' => $capability, + 'arguments' => $arguments, + 'granted' => $granted, + 'count' => 1, + 'frames' => $this->backtrace ? array_values(array_filter( + array_map(static fn (array $frame): array => ['file' => $frame['file'] ?? ''], debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 15)), + static fn (array $frame): bool => $frame['file'] !== '', + )) : [], + ]; + } +} diff --git a/src/Recording/HttpRecorder.php b/src/Recording/HttpRecorder.php new file mode 100644 index 0000000..a7899f9 --- /dev/null +++ b/src/Recording/HttpRecorder.php @@ -0,0 +1,115 @@ +}> + */ + private array $calls = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_filter')) { + return; + } + + $this->installed = true; + + add_filter('http_request_args', fn (mixed $args, mixed $url): mixed => $this->starts($args, (string) $url), PHP_INT_MAX, 2); + add_filter('pre_http_request', fn (mixed $pre, mixed $args): mixed => $this->answeredInPlace($pre, $args), PHP_INT_MAX, 2); + add_action('http_api_debug', function (mixed $response, mixed $context, mixed $transport, mixed $args): void { + $this->ends($response, $args, is_string($transport) ? $transport : null); + }, PHP_INT_MAX, 4); + } + + /** + * @return list}> + */ + public function calls(): array + { + return array_values($this->calls); + } + + public function reset(): void + { + $this->calls = []; + } + + private function starts(mixed $args, string $url): mixed + { + if (! is_array($args)) { + return $args; + } + + $id = (string) count($this->calls); + $args[self::TAG] = $id; + + $this->calls[$id] = [ + 'method' => strtoupper(is_string($args['method'] ?? null) ? $args['method'] : 'GET'), + 'url' => $url, + 'start' => microtime(true), + 'end' => null, + 'status' => null, + 'error' => null, + 'transport' => null, + 'shortCircuited' => false, + 'frames' => array_values(array_filter( + array_map(static fn (array $frame): array => ['file' => $frame['file'] ?? ''], debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 20)), + static fn (array $frame): bool => $frame['file'] !== '', + )), + ]; + + return $args; + } + + private function answeredInPlace(mixed $pre, mixed $args): mixed + { + if ($pre !== false && is_array($args) && isset($this->calls[$args[self::TAG] ?? ''])) { + $id = $args[self::TAG]; + $this->calls[$id]['shortCircuited'] = true; + $this->finish($id, $pre, null); + } + + return $pre; + } + + private function ends(mixed $response, mixed $args, ?string $transport): void + { + if (is_array($args) && isset($this->calls[$args[self::TAG] ?? ''])) { + $this->finish($args[self::TAG], $response, $transport); + } + } + + private function finish(string $id, mixed $response, ?string $transport): void + { + $this->calls[$id]['end'] = microtime(true); + $this->calls[$id]['transport'] = $transport; + + if ($response instanceof \WP_Error) { + $this->calls[$id]['error'] = $response->get_error_message(); + + return; + } + + if (is_array($response)) { + $code = $response['response']['code'] ?? null; + $this->calls[$id]['status'] = is_int($code) || is_string($code) ? $code : null; + } + } +} diff --git a/src/Recording/LanguageRecorder.php b/src/Recording/LanguageRecorder.php new file mode 100644 index 0000000..7ae34c9 --- /dev/null +++ b/src/Recording/LanguageRecorder.php @@ -0,0 +1,59 @@ + + */ + private array $files = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_filter')) { + return; + } + + $this->installed = true; + + // WordPress 6.5+ asks for .l10n.php first, then .mo, through load_translation_file + add_filter('load_translation_file', function (mixed $file, mixed $domain): mixed { + $this->record((string) $domain, (string) $file, 'php / mo'); + + return $file; + }, PHP_INT_MAX, 2); + add_filter('load_script_translation_file', function (mixed $file, mixed $handle, mixed $domain): mixed { + if (is_string($file)) { + $this->record((string) $domain, $file, 'script ('.$handle.')'); + } + + return $file; + }, PHP_INT_MAX, 3); + } + + /** + * @return list + */ + public function files(): array + { + return $this->files; + } + + public function reset(): void + { + $this->files = []; + } + + private function record(string $domain, string $file, string $kind): void + { + $this->files[] = ['domain' => $domain, 'file' => $file, 'found' => $file !== '' && is_readable($file), 'kind' => $kind]; + } +} diff --git a/src/Recording/QueryTracer.php b/src/Recording/QueryTracer.php new file mode 100644 index 0000000..65a8290 --- /dev/null +++ b/src/Recording/QueryTracer.php @@ -0,0 +1,126 @@ +installed || ! function_exists('add_filter')) { + return; + } + + $this->installed = true; + + add_filter('log_query_custom_data', fn (mixed $data): mixed => $this->trace($data), PHP_INT_MAX, 1); + add_filter('query', function (mixed $query): mixed { + $this->completePrevious(); + + return $query; + }, PHP_INT_MIN, 1); + } + + /** + * Copy the last query's error and rows, which no later query will do. + */ + public function completePrevious(): void + { + global $wpdb; + + if (! is_object($wpdb) || ! isset($wpdb->queries) || ! is_array($wpdb->queries) || $wpdb->queries === []) { + return; + } + + $index = array_key_last($wpdb->queries); + $data = $wpdb->queries[$index][4] ?? []; + + if (! is_array($data) || ! isset($data[self::KEY]) || array_key_exists('rows', $data[self::KEY])) { + return; + } + + $data[self::KEY]['error'] = is_string($wpdb->last_error ?? null) && $wpdb->last_error !== '' ? $wpdb->last_error : null; + $data[self::KEY]['rows'] = $this->rows($wpdb); + $wpdb->queries[$index][4] = $data; + } + + private function trace(mixed $data): mixed + { + global $wpdb; + + $data = is_array($data) ? $data : []; + $count = is_object($wpdb) && isset($wpdb->queries) && is_array($wpdb->queries) ? count($wpdb->queries) : 0; + + $data[self::KEY] = [ + 'frames' => $count < $this->softLimit ? $this->frames() : [], + ]; + + return $data; + } + + /** + * The calls that led to the query, innermost first, without `wpdb` and + * WordPress's hook plumbing. + * + * @return list + */ + private function frames(): array + { + $frames = []; + + foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, self::FRAMES + 8) as $frame) { + $class = $frame['class'] ?? ''; + + // The tracer itself, wpdb, and WordPress's hook plumbing say nothing about who asked + if ($class === self::class || is_a($class, 'wpdb', true) || $class === 'WP_Hook' || ! isset($frame['file']) + || str_ends_with($frame['file'], '/class-wpdb.php') || str_ends_with($frame['file'], '/wp-includes/plugin.php')) { + continue; + } + + $frames[] = [ + 'file' => $frame['file'], + 'line' => (int) ($frame['line'] ?? 0), + 'call' => ($class !== '' ? $class.($frame['type'] ?? '::') : '').$frame['function'], + ]; + + if (count($frames) >= self::FRAMES) { + break; + } + } + + return $frames; + } + + private function rows(object $wpdb): ?int + { + $rows = (int) ($wpdb->rows_affected ?? 0); + + return $rows > 0 ? $rows : (isset($wpdb->num_rows) ? (int) $wpdb->num_rows : null); + } +} diff --git a/src/Support/Components.php b/src/Support/Components.php new file mode 100644 index 0000000..7afe7b0 --- /dev/null +++ b/src/Support/Components.php @@ -0,0 +1,170 @@ +|null + */ + private ?array $directories = null; + + /** + * @var array + */ + private array $cache = []; + + /** + * @param array|null $directories Directory => owner, longest first; detected when null + */ + public function __construct(?array $directories = null) + { + if ($directories !== null) { + uksort($directories, static fn (string $a, string $b): int => strlen($b) <=> strlen($a)); + $this->directories = $directories; + } + } + + /** + * The owner of a file, as a label: `plugin: woocommerce`, `theme: default`, `core`… + */ + public function of(string $file): string + { + if ($file === '') { + return 'unknown'; + } + + return $this->cache[$file] ??= $this->label($this->owner($file)); + } + + /** + * The owner of a backtrace, innermost frame first. + * + * The project's own code wins (plugin, mu-plugin, theme, module, app), as + * in Query Monitor; otherwise the innermost frame decides. A query + * WordPress makes while Pollora boots it is core's, not Pollora's. + * + * @param list $frames + */ + public function ofTrace(array $frames): string + { + $best = null; + $bestRank = PHP_INT_MAX; + $innermost = null; + + foreach ($frames as $frame) { + if (! isset($frame['file']) || $frame['file'] === '' || str_contains($frame['file'], '/pollora/debugbar/')) { + continue; + } + + $owner = $this->owner($frame['file']); + $innermost ??= $owner; + $rank = array_search($owner['kind'], self::PRIORITY, true); + + if ($rank !== false && $rank < $bestRank) { + $best = $owner; + $bestRank = $rank; + } + } + + $owner = $best ?? $innermost; + + return $owner === null ? 'unknown' : $this->label($owner); + } + + /** + * @return array{kind: string, name: string|null} + */ + private function owner(string $file): array + { + $file = str_replace('\\', '/', $file); + + foreach ($this->directories() as $directory => $owner) { + if (! str_starts_with($file, $directory)) { + continue; + } + + if ($owner['name'] !== null) { + return $owner; + } + + // Plugins, themes, modules and packages are named by their first directory + $rest = substr($file, strlen($directory)); + $segments = explode('/', $rest); + $name = $owner['kind'] === 'vendor' ? implode('/', array_slice($segments, 0, 2)) : $segments[0]; + + return ['kind' => $owner['kind'], 'name' => $owner['kind'] === 'plugin' && ! str_contains($rest, '/') ? basename($name, '.php') : $name]; + } + + return ['kind' => 'unknown', 'name' => null]; + } + + /** + * @param array{kind: string, name: string|null} $owner + */ + private function label(array $owner): string + { + return $owner['name'] === null || $owner['name'] === '' ? $owner['kind'] : "{$owner['kind']}: {$owner['name']}"; + } + + /** + * @return array + */ + private function directories(): array + { + if ($this->directories !== null) { + return $this->directories; + } + + $directories = []; + $add = static function (?string $path, string $kind, ?string $name = null) use (&$directories): void { + if ($path !== null && $path !== '') { + $directories[rtrim(str_replace('\\', '/', $path), '/').'/'] = ['kind' => $kind, 'name' => $name]; + } + }; + + $add(defined('WP_PLUGIN_DIR') ? WP_PLUGIN_DIR : null, 'plugin'); + $add(defined('WPMU_PLUGIN_DIR') ? WPMU_PLUGIN_DIR : null, 'mu-plugin', ''); + + if (function_exists('get_theme_root')) { + $add(get_theme_root(), 'theme'); + } + + if (function_exists('base_path')) { + $add(base_path('themes'), 'theme'); + $add(base_path('Modules'), 'module'); + $add(base_path('vendor/pollora'), 'pollora', ''); + $add(base_path('vendor/laravel'), 'laravel', ''); + $add(base_path('vendor'), 'vendor'); + } + + if (function_exists('app_path')) { + $add(app_path(), 'app', ''); + } + + if (defined('ABSPATH')) { + $add(ABSPATH, 'core', ''); + } + + uksort($directories, static fn (string $a, string $b): int => strlen($b) <=> strlen($a)); + + return $this->directories = $directories; + } +} diff --git a/tests/Feature/CollectorRegistrarTest.php b/tests/Feature/CollectorRegistrarTest.php index fc20153..2129179 100644 --- a/tests/Feature/CollectorRegistrarTest.php +++ b/tests/Feature/CollectorRegistrarTest.php @@ -17,7 +17,7 @@ function registrar(): CollectorRegistrar registrar()->register($debugbar); - foreach (['pollora', 'wp_request', 'wp_queries', 'wp_hooks', 'wp_timeline'] as $name) { + foreach (['pollora', 'pollora_doctor', 'wp_request', 'wp_queries', 'wp_hooks', 'wp_timeline', 'wp_http', 'wp_cache', 'wp_capabilities', 'wp_blocks', 'wp_assets', 'wp_languages'] as $name) { expect($debugbar->hasCollector($name))->toBeTrue(); } }); diff --git a/tests/Feature/DoctorTest.php b/tests/Feature/DoctorTest.php new file mode 100644 index 0000000..eb3c612 --- /dev/null +++ b/tests/Feature/DoctorTest.php @@ -0,0 +1,75 @@ +name; + } + + public function label(): string + { + return ucfirst($this->name); + } + + public function runsIn(): array + { + return [RunContext::Http]; + } + + public function run(RunContext $context): CheckResult + { + return $this->result; + } + }; +} + +beforeEach(function (): void { + $this->app['router']->get('_debugbar/pollora/doctor', DoctorController::class); + $this->app->instance(Doctor::class, new Doctor([ + check('cache', CheckResult::ok('Fine.')), + check('patterns', CheckResult::error('Broken pattern.', ['patterns/hero.php'], 'Fix the markup.')), + check('theme', CheckResult::warning('No build.')), + ])); +}); + +it('refuses callers outside local and private addresses, as Debugbar does for stored requests', function (): void { + // Testbench runs as "testing", where Debugbar keeps its storage closed + $this->getJson('_debugbar/pollora/doctor') + ->assertForbidden() + ->assertJsonPath('message', fn (string $message): bool => str_contains($message, 'debugbar.storage.open')); +}); + +it('runs the web checks and lists errors first', function (): void { + config(['debugbar.force_allow_enable' => true, 'debugbar.enabled' => true, 'debugbar.storage.open' => true]); + $this->collectingDebugbar(); + + $this->getJson('_debugbar/pollora/doctor') + ->assertOk() + ->assertJsonPath('checks.0.id', 'patterns') + ->assertJsonPath('checks.0.fix', 'Fix the markup.') + ->assertJsonPath('checks.0.details', ['patterns/hero.php']) + ->assertJsonPath('checks.1.status', 'warning') + ->assertJsonPath('checks.2.status', 'ok'); +}); + +it('gives its tab the address to call and a widget of its own, inlined', function (): void { + $collector = new DoctorCollector('/_debugbar/pollora/doctor'); + + expect($collector->collect()['data'])->toBe(['url' => '/_debugbar/pollora/doctor']) + ->and($collector->getWidgets()['pollora_doctor']['widget'])->toBe('PhpDebugBar.Widgets.PolloraDoctorWidget') + ->and($collector->getAssets()['inline_js']['pollora-doctor-widget'])->toContain('PolloraDoctorWidget'); +}); diff --git a/tests/Unit/Collectors/WpQueriesCollectorTest.php b/tests/Unit/Collectors/WpQueriesCollectorTest.php index 155b7a6..51a401b 100644 --- a/tests/Unit/Collectors/WpQueriesCollectorTest.php +++ b/tests/Unit/Collectors/WpQueriesCollectorTest.php @@ -4,6 +4,8 @@ use DebugBar\DataFormatter\DataFormatter; use Pollora\Debugbar\Collectors\WpQueriesCollector; +use Pollora\Debugbar\Recording\QueryTracer; +use Pollora\Debugbar\Support\Components; /** * WordPress keeps each query as [sql, seconds, caller, start, data] once @@ -62,3 +64,32 @@ function queriesFrom(WpQueriesCollector $collector): array expect(queriesFrom(new WpQueriesCollector)['nb_statements'])->toBe(0); }); + +it('uses the tracer\'s backtrace, error and rows, and groups queries by component', function (): void { + $GLOBALS['wpdb']->queries = [[ + 'SELECT * FROM wp_nope', 0.001, 'ignored caller string', 1.0, + [QueryTracer::KEY => [ + 'frames' => [ + ['file' => '/site/public/cms/wp-includes/option.php', 'line' => 12, 'call' => 'get_option'], + ['file' => '/site/public/content/plugins/acme/acme.php', 'line' => 30, 'call' => 'acme_boot'], + ], + 'error' => "Table 'wp_nope' doesn't exist", + 'rows' => 0, + ]], + ]]; + + $components = new Components([ + '/site/public/content/plugins/' => ['kind' => 'plugin', 'name' => null], + '/site/public/cms/' => ['kind' => 'core', 'name' => ''], + ]); + + $data = queriesFrom(new WpQueriesCollector(components: $components)); + $statement = $data['statements'][0]; + + expect($statement['connection'])->toBe('wpdb · plugin: acme') + ->and($statement['is_success'])->toBeFalse() + ->and($statement['error_message'])->toBe("Table 'wp_nope' doesn't exist") + ->and($statement['row_count'])->toBe(0) + ->and($statement['backtrace'][1])->toBe('acme_boot — /site/public/content/plugins/acme/acme.php:30') + ->and($data['nb_failed_statements'])->toBe(1); +}); diff --git a/tests/Unit/Recording/QueryTracerTest.php b/tests/Unit/Recording/QueryTracerTest.php new file mode 100644 index 0000000..a78c7b7 --- /dev/null +++ b/tests/Unit/Recording/QueryTracerTest.php @@ -0,0 +1,74 @@ + + */ +function tracerHooks(QueryTracer $tracer): ArrayObject +{ + $hooks = new ArrayObject; + + foreach (['log_query_custom_data', 'query'] as $filter) { + Filters\expectAdded($filter)->zeroOrMoreTimes()->whenHappen(function (callable $callback) use ($hooks, $filter): void { + $hooks[$filter] = $callback; + }); + } + + $tracer->install(); + + return $hooks; +} + +beforeEach(function (): void { + $GLOBALS['wpdb'] = (object) ['queries' => [], 'last_error' => '', 'rows_affected' => 0, 'num_rows' => 0]; +}); + +afterEach(function (): void { + unset($GLOBALS['wpdb']); +}); + +it('adds the backtrace to the data WordPress saves with a query', function (): void { + $hooks = tracerHooks(new QueryTracer); + + $data = $hooks['log_query_custom_data']([]); + + expect($data[QueryTracer::KEY]['frames'])->not->toBeEmpty() + ->and($data[QueryTracer::KEY]['frames'][0])->toHaveKeys(['file', 'line', 'call']); +}); + +it('keeps no backtrace past the soft limit', function (): void { + $GLOBALS['wpdb']->queries = array_fill(0, 3, ['SELECT 1', 0.1, '', 0.0, []]); + $hooks = tracerHooks(new QueryTracer(softLimit: 2)); + + expect($hooks['log_query_custom_data']([])[QueryTracer::KEY]['frames'])->toBe([]); +}); + +it("copies the previous query's error and rows when the next one starts", function (): void { + $hooks = tracerHooks(new QueryTracer); + $GLOBALS['wpdb']->queries[] = ['SELECT nope', 0.1, '', 0.0, [QueryTracer::KEY => ['frames' => []]]]; + $GLOBALS['wpdb']->last_error = "Table 'nope' doesn't exist"; + $GLOBALS['wpdb']->num_rows = 0; + + $query = $hooks['query']('SELECT 2'); + + expect($query)->toBe('SELECT 2') + ->and($GLOBALS['wpdb']->queries[0][4][QueryTracer::KEY]['error'])->toBe("Table 'nope' doesn't exist") + ->and($GLOBALS['wpdb']->queries[0][4][QueryTracer::KEY]['rows'])->toBe(0); +}); + +it('completes a query once, so a later flush does not overwrite it', function (): void { + $tracer = new QueryTracer; + $GLOBALS['wpdb']->queries[] = ['UPDATE x', 0.1, '', 0.0, [QueryTracer::KEY => ['frames' => []]]]; + $GLOBALS['wpdb']->rows_affected = 3; + + $tracer->completePrevious(); + $GLOBALS['wpdb']->rows_affected = 0; + $GLOBALS['wpdb']->last_error = 'later'; + $tracer->completePrevious(); + + expect($GLOBALS['wpdb']->queries[0][4][QueryTracer::KEY])->toMatchArray(['rows' => 3, 'error' => null]); +}); diff --git a/tests/Unit/Recording/RecordersTest.php b/tests/Unit/Recording/RecordersTest.php new file mode 100644 index 0000000..9ea7e59 --- /dev/null +++ b/tests/Unit/Recording/RecordersTest.php @@ -0,0 +1,187 @@ + $hooks + */ +function captured(array $hooks): ArrayObject +{ + $callbacks = new ArrayObject; + + foreach ($hooks as $hook) { + Filters\expectAdded($hook)->zeroOrMoreTimes()->whenHappen(function (callable $callback) use ($callbacks, $hook): void { + $callbacks[$hook] = $callback; + }); + Actions\expectAdded($hook)->zeroOrMoreTimes()->whenHappen(function (callable $callback) use ($callbacks, $hook): void { + $callbacks[$hook] = $callback; + }); + } + + return $callbacks; +} + +function noComponents(): Components +{ + return new Components([]); +} + +describe('HTTP calls', function (): void { + it('pairs each response with its request, even when a plugin answers in its place', function (): void { + $hooks = captured(['http_request_args', 'pre_http_request', 'http_api_debug']); + $recorder = new HttpRecorder; + $recorder->install(); + + $first = $hooks['http_request_args'](['method' => 'post'], 'https://api.example.test/orders'); + $second = $hooks['http_request_args'](['method' => 'GET'], 'https://api.example.test/stock'); + $hooks['pre_http_request'](['response' => ['code' => 200]], $second); + $hooks['http_api_debug'](['response' => ['code' => 201]], 'response', 'WpOrg\\Requests\\Requests', $first); + + $rows = (new WpHttpCollector($recorder, noComponents()))->setDataFormatter(new DataFormatter)->collect()['data']['data']; + + expect(array_keys($rows))->toBe(['1. POST https://api.example.test/orders', '2. GET https://api.example.test/stock']) + ->and($rows['1. POST https://api.example.test/orders']['result'])->toBe('201') + ->and($rows['1. POST https://api.example.test/orders']['transport'])->toBe('WpOrg\\Requests\\Requests') + ->and($rows['2. GET https://api.example.test/stock']['result'])->toBe('200 (answered by pre_http_request)'); + }); + + it('leaves a pre_http_request that lets the call through untouched', function (): void { + $hooks = captured(['http_request_args', 'pre_http_request', 'http_api_debug']); + $recorder = new HttpRecorder; + $recorder->install(); + + $args = $hooks['http_request_args']([], 'https://example.test'); + + expect($hooks['pre_http_request'](false, $args))->toBeFalse() + ->and($recorder->calls()[0]['end'])->toBeNull(); + }); +}); + +describe('capability checks', function (): void { + it('counts identical checks once, and tells granted from refused', function (): void { + $hooks = captured(['user_has_cap']); + $recorder = new CapabilityRecorder; + $recorder->install(); + + $allcaps = ['edit_posts' => true]; + foreach ([1, 2, 3] as $ignored) { + $hooks['user_has_cap']($allcaps, ['edit_posts'], ['edit_posts', 5]); + } + $returned = $hooks['user_has_cap']($allcaps, ['edit_others_posts'], ['edit_post', 5, 42]); + + $rows = (new WpCapabilitiesCollector($recorder, noComponents()))->collect()['data']['data']; + + expect($returned)->toBe($allcaps) + ->and($rows['edit_posts'])->toMatchArray(['result' => 'granted', 'user' => '#5', 'count' => 3]) + ->and($rows['edit_post(42)'])->toMatchArray(['result' => 'refused', 'count' => 1]); + }); + + it('refuses a check that maps to no capability, as WordPress does', function (): void { + $hooks = captured(['user_has_cap']); + $recorder = new CapabilityRecorder; + $recorder->install(); + + $hooks['user_has_cap'](['read' => true], [], ['do_not_allow', 0]); + + expect($recorder->checks()[0]['granted'])->toBeFalse(); + }); +}); + +describe('blocks', function (): void { + it('times each block type, counting inner blocks inside their parent', function (): void { + $hooks = captured(['pre_render_block', 'render_block']); + $recorder = new BlockRecorder; + $recorder->install(); + + $hooks['pre_render_block'](null, ['blockName' => 'core/group']); + $hooks['pre_render_block'](null, ['blockName' => 'core/paragraph']); + $hooks['render_block']('

a

'); + $hooks['pre_render_block'](null, ['blockName' => 'core/paragraph']); + $hooks['render_block']('

b

'); + $hooks['render_block']('
'); + + expect($recorder->blocks()['core/paragraph'])->toMatchArray(['count' => 2, 'maxDepth' => 1]) + ->and($recorder->blocks()['core/group'])->toMatchArray(['count' => 1, 'maxDepth' => 0]); + }); + + it('opens nothing for a block a plugin answers in pre_render_block', function (): void { + $hooks = captured(['pre_render_block', 'render_block']); + $recorder = new BlockRecorder; + $recorder->install(); + + $hooks['pre_render_block']('

cached

', ['blockName' => 'acme/cached']); + + expect($recorder->blocks())->toBe([]); + }); + + it('groups bindings by source, field and attribute', function (): void { + $recorder = new BlockRecorder; + $binding = fn (bool $cached, bool $hasValue): BindingResolved => new BindingResolved('acme/event', 'seats', 'content', 7, $cached ? 0.0 : 1.5, $cached, $hasValue); + $recorder->recordBinding($binding(false, true)); + $recorder->recordBinding($binding(true, true)); + $recorder->recordBinding($binding(false, false)); + + $rows = (new WpBlocksCollector($recorder))->setDataFormatter(new DataFormatter)->collect()['data']['data']; + + expect($rows['binding acme/event.seats → content'])->toMatchArray([ + 'count' => 3, + 'kind' => 'binding', + 'detail' => 'post 7, 1 from cache, 1 without value', + ]); + }); +}); + +describe('transients, languages and async actions', function (): void { + it('keeps the transients set, with their size and expiration', function (): void { + $hooks = captured(['set_transient', 'set_site_transient']); + $recorder = new CacheRecorder; + $recorder->install(); + + $hooks['set_transient']('acme_rates', ['eur' => 1.0], 3600); + $hooks['set_site_transient']('acme_feed', 'x', 0); + + expect($recorder->transients())->toHaveCount(2) + ->and($recorder->transients()[0])->toMatchArray(['name' => 'acme_rates', 'network' => false, 'expiration' => 3600, 'size' => strlen(serialize(['eur' => 1.0]))]) + ->and($recorder->transients()[1]['network'])->toBeTrue(); + }); + + it('keeps the translation files asked for, and whether they exist', function (): void { + $hooks = captured(['load_translation_file', 'load_script_translation_file']); + $recorder = new LanguageRecorder; + $recorder->install(); + + $returned = $hooks['load_translation_file'](__FILE__, 'acme'); + $hooks['load_translation_file']('/nowhere/acme-fr_FR.mo', 'acme'); + + expect($returned)->toBe(__FILE__) + ->and(array_column($recorder->files(), 'found'))->toBe([true, false]); + }); + + it('keeps the async actions the request queued', function (): void { + $hooks = captured(['pollora/async/dispatched']); + $recorder = new AsyncRecorder; + $recorder->install(); + + $hooks['pollora/async/dispatched']((object) ['hook' => 'save_post', 'handler' => 'App\\Sync@handle', 'driver' => 'queue'], 30); + + expect($recorder->dispatched())->toBe([['hook' => 'save_post', 'handler' => 'App\\Sync@handle', 'driver' => 'queue', 'delay' => 30]]); + }); +}); diff --git a/tests/Unit/Support/ComponentsTest.php b/tests/Unit/Support/ComponentsTest.php new file mode 100644 index 0000000..181912e --- /dev/null +++ b/tests/Unit/Support/ComponentsTest.php @@ -0,0 +1,55 @@ + ['kind' => 'plugin', 'name' => null], + '/site/public/content/mu-plugins/' => ['kind' => 'mu-plugin', 'name' => ''], + '/site/themes/' => ['kind' => 'theme', 'name' => null], + '/site/vendor/pollora/' => ['kind' => 'pollora', 'name' => ''], + '/site/vendor/' => ['kind' => 'vendor', 'name' => null], + '/site/app/' => ['kind' => 'app', 'name' => ''], + '/site/public/cms/' => ['kind' => 'core', 'name' => ''], + ]); +} + +it('names the plugin, theme or package a file belongs to', function (): void { + expect(components()->of('/site/public/content/plugins/woocommerce/includes/class-wc.php'))->toBe('plugin: woocommerce') + ->and(components()->of('/site/public/content/plugins/hello.php'))->toBe('plugin: hello') + ->and(components()->of('/site/themes/default/functions.php'))->toBe('theme: default') + ->and(components()->of('/site/vendor/spatie/laravel-data/src/Data.php'))->toBe('vendor: spatie/laravel-data') + ->and(components()->of('/site/vendor/pollora/framework/src/Foo.php'))->toBe('pollora') + ->and(components()->of('/site/public/cms/wp-includes/option.php'))->toBe('core') + ->and(components()->of('/elsewhere/file.php'))->toBe('unknown'); +}); + +it("gives a backtrace to the project's own code it passes through", function (): void { + $trace = [ + ['file' => '/site/public/cms/wp-includes/option.php'], + ['file' => '/site/vendor/pollora/framework/src/Foo.php'], + ['file' => '/site/public/content/plugins/woocommerce/includes/class-wc.php'], + ['file' => '/site/public/cms/wp-settings.php'], + ]; + + expect(components()->ofTrace($trace))->toBe('plugin: woocommerce'); +}); + +it('gives a backtrace through no project code to its innermost frame', function (): void { + $trace = [ + ['file' => '/site/public/cms/wp-includes/option.php'], + ['file' => '/site/vendor/pollora/framework/src/WordPress/Bootstrap.php'], + ]; + + expect(components()->ofTrace($trace))->toBe('core'); +}); + +it('prefers a plugin to a theme when both are in the trace', function (): void { + expect(components()->ofTrace([ + ['file' => '/site/themes/default/functions.php'], + ['file' => '/site/public/content/plugins/acme/acme.php'], + ]))->toBe('plugin: acme'); +}); diff --git a/tests/e2e/fixtures/acme-debugbar.php b/tests/e2e/fixtures/acme-debugbar.php index 29ea479..c0e6d15 100644 --- a/tests/e2e/fixtures/acme-debugbar.php +++ b/tests/e2e/fixtures/acme-debugbar.php @@ -22,6 +22,18 @@ add_action('init', function (): void { do_action('pollora/debugbar/message', 'Acme cart {id} rebuilt', 'info', ['id' => 'c-42']); do_action('qm/warning', 'Written for Query Monitor'); + + // An HTTP call answered in place, so the test needs no network + add_filter('pre_http_request', function ($pre, $args, $url) { + return str_starts_with($url, 'https://api.acme.test/') + ? ['headers' => [], 'body' => '{}', 'response' => ['code' => 200, 'message' => 'OK'], 'cookies' => [], 'filename' => null] + : $pre; + }, 10, 3); + wp_remote_get('https://api.acme.test/stock'); + + // WordPress only announces a transient whose value changed + set_transient('acme_rates', ['eur' => 1.0, 'at' => microtime(true)], HOUR_IN_SECONDS); + current_user_can('edit_posts'); }); if (class_exists(Collector::class)) { diff --git a/tests/e2e/specs/debugbar.spec.ts b/tests/e2e/specs/debugbar.spec.ts index be0df36..5fc935a 100644 --- a/tests/e2e/specs/debugbar.spec.ts +++ b/tests/e2e/specs/debugbar.spec.ts @@ -53,7 +53,7 @@ test('shows the queries WordPress ran and the hooks that fired', async ({ page } const data = firstDataset(await debugbar(page)); expect(data.wp_queries.nb_statements).toBeGreaterThan(0); - expect(data.wp_queries.statements[0].connection).toBe('wpdb'); + expect(data.wp_queries.statements[0].connection).toMatch(/^wpdb/); expect(data.wp_hooks.data.data.init.calls).toBeGreaterThan(0); }); @@ -82,3 +82,49 @@ test('shows what a plugin and a package add, labelled as theirs', async ({ page expect.arrayContaining(['Acme cart c-42 rebuilt', 'Written for Query Monitor']), ); }); + +test('groups WordPress queries by component and keeps their backtrace', async ({ page }) => { + await page.goto(''); + const statements = firstDataset(await debugbar(page)).wp_queries.statements; + + expect(statements.map((statement: any) => statement.connection)).toContain('wpdb · core'); + expect(statements[0].backtrace.length).toBeGreaterThan(0); +}); + +test('lists the HTTP calls, transients and capability checks of the request', async ({ page }) => { + await page.goto(''); + const data = firstDataset(await debugbar(page)); + + expect(data.wp_http.data.data['1. GET https://api.acme.test/stock'].result).toBe('200 (answered by pre_http_request)'); + expect(JSON.stringify(data.wp_cache.data['Transients set'])).toContain('acme_rates'); + expect(data.wp_capabilities.data.data.edit_posts.result).toBe('refused'); +}); + +test('shows the blocks of a post and the theme’s Vite build', async ({ page }) => { + await page.goto('hello-world/'); + const data = firstDataset(await debugbar(page)); + + expect(data.wp_blocks.data.data['core/paragraph'].count).toBeGreaterThan(0); + expect(data.wp_assets.data.data['vite: theme'].note).toBe('build (manifest)'); +}); + +test('keeps the request WordPress redirected from', async ({ page }) => { + await page.goto('?p=1'); + await expect(page).toHaveURL(/hello-world/); + + await expect.poll(async () => page.evaluate( + () => Object.values((window as any).phpdebugbar.datasets).map((dataset: any) => dataset.__meta?.uri), + )).toContain('/?p=1'); +}); + +test('runs the doctor on demand from its tab', async ({ page }) => { + await page.goto(''); + await debugbar(page); + + await page.evaluate(() => (window as any).phpdebugbar.showTab('pollora_doctor')); + const panel = page.locator('.phpdebugbar-panel.phpdebugbar-active'); + await panel.getByRole('button', { name: 'Run doctor' }).click(); + + await expect(panel.locator('tr').first()).toBeVisible(); + await expect(panel).toContainText(/\d+ (ok|warning|error)/); +}); From 7153d82b43026f970366738b7cfde6d72354fc0a Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 16:18:00 +0200 Subject: [PATCH 05/10] ci: run on every pull request, stacked ones included --- .github/workflows/tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c4c4edc..9ed9ef3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,6 @@ on: push: branches: [ main, develop ] pull_request: - branches: [ main, develop ] workflow_dispatch: jobs: From 78b25b9bb25a1bfb495a4c37aa0ae69fe1608648 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 17:18:59 +0200 Subject: [PATCH 06/10] fix: lighter query traces, attribution before WordPress defines its directories, readable doctor errors Each query keeps its 10 innermost frames to show, and is attributed from its whole trace when it runs: full traces made most of the bar's weight (309 KB of 411 on a WooCommerce shop page, now 154). Component directories are not cached until WordPress defines where plugins live, which had left early lookups without them. The Doctor tab says what came back when the answer is not JSON (a proxy's 404 while a server restarts) instead of a JSON syntax error. The Pollora tab adds the database server, memory limits and web server Query Monitor shows. --- resources/doctor-widget.js | 13 +++++++++-- src/Collectors/PolloraCollector.php | 9 ++++++++ src/Collectors/WpQueriesCollector.php | 2 +- src/DebugbarServiceProvider.php | 5 +++- src/Recording/QueryTracer.php | 23 +++++++++++++++++-- src/Support/Components.php | 29 +++++++++++++++++++----- tests/Unit/Recording/QueryTracerTest.php | 11 +++++++++ tests/Unit/Support/ComponentsTest.php | 8 +++++++ 8 files changed, 88 insertions(+), 12 deletions(-) diff --git a/resources/doctor-widget.js b/resources/doctor-widget.js index 94aae99..33c08cd 100644 --- a/resources/doctor-widget.js +++ b/resources/doctor-widget.js @@ -46,10 +46,19 @@ try { const response = await fetch(url, { headers: { Accept: 'application/json' }, credentials: 'same-origin' }); - const body = await response.json(); + const text = await response.text(); + const isJson = (response.headers.get('content-type') || '').includes('json'); + + // A proxy or a restarting server answers with plain text or + // HTML: say what came back rather than a JSON syntax error + if (! isJson) { + throw new Error('HTTP ' + response.status + ': ' + text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120)); + } + + const body = JSON.parse(text); if (! response.ok) { - throw new Error(body.message || response.status); + throw new Error(body.message || 'HTTP ' + response.status); } const counts = {}; diff --git a/src/Collectors/PolloraCollector.php b/src/Collectors/PolloraCollector.php index 8cce2d6..4127ff0 100644 --- a/src/Collectors/PolloraCollector.php +++ b/src/Collectors/PolloraCollector.php @@ -267,10 +267,19 @@ private function wordpress(): array } } + global $wpdb; + return [ 'environment' => function_exists('wp_get_environment_type') ? wp_get_environment_type() : null, 'constants' => $constants, 'drop-ins' => $dropIns === [] ? 'none' : implode(', ', $dropIns), + 'database server' => is_object($wpdb) && method_exists($wpdb, 'db_server_info') ? (string) $wpdb->db_server_info() : null, + 'memory limit' => sprintf( + 'PHP %s, WordPress %s', + (string) ini_get('memory_limit'), + defined('WP_MEMORY_LIMIT') ? (string) WP_MEMORY_LIMIT : '—', + ), + 'web server' => is_string($_SERVER['SERVER_SOFTWARE'] ?? null) ? $_SERVER['SERVER_SOFTWARE'] : null, ]; } diff --git a/src/Collectors/WpQueriesCollector.php b/src/Collectors/WpQueriesCollector.php index f2ad55e..ce4a006 100644 --- a/src/Collectors/WpQueriesCollector.php +++ b/src/Collectors/WpQueriesCollector.php @@ -97,7 +97,7 @@ protected function data(): array if ($traced !== null && ($traced['frames'] ?? []) !== []) { $frames = array_map(fn (array $frame): string => sprintf('%s — %s:%d', $frame['call'], $this->relativePath($frame['file']), $frame['line']), $traced['frames']); - $component = $this->components?->ofTrace($traced['frames']); + $component = is_string($traced['component'] ?? null) ? $traced['component'] : $this->components?->ofTrace($traced['frames']); $source = $traced['frames'][0]['call']; } else { $frames = $index < $this->softLimit ? $this->frames((string) ($query[2] ?? '')) : []; diff --git a/src/DebugbarServiceProvider.php b/src/DebugbarServiceProvider.php index 682c49f..2028be9 100644 --- a/src/DebugbarServiceProvider.php +++ b/src/DebugbarServiceProvider.php @@ -39,7 +39,10 @@ public function register(): void $this->app->singleton(RequestRecorder::class); $this->app->singleton(CollectorRegistrar::class); $this->app->singleton(Components::class, fn (): Components => new Components); - $this->app->singleton(QueryTracer::class, fn ($app): QueryTracer => new QueryTracer((int) $app->make('config')->get('debugbar-pollora.options.wp_queries.soft_limit', 100))); + $this->app->singleton(QueryTracer::class, fn ($app): QueryTracer => new QueryTracer( + (int) $app->make('config')->get('debugbar-pollora.options.wp_queries.soft_limit', 100), + $app->make(Components::class), + )); $this->app->singleton(HttpRecorder::class); $this->app->singleton(CacheRecorder::class); $this->app->singleton(LanguageRecorder::class); diff --git a/src/Recording/QueryTracer.php b/src/Recording/QueryTracer.php index 65a8290..707c580 100644 --- a/src/Recording/QueryTracer.php +++ b/src/Recording/QueryTracer.php @@ -4,6 +4,8 @@ namespace Pollora\Debugbar\Recording; +use Pollora\Debugbar\Support\Components; + /** * Adds what `SAVEQUERIES` leaves out to each query `$wpdb` keeps: the full * backtrace, the error and the number of rows. @@ -23,12 +25,17 @@ final class QueryTracer /** Key of the data this tracer adds to each query's custom data. */ public const string KEY = 'pollora_debugbar'; - private const int FRAMES = 25; + /** Frames read to find who asked for the query. */ + private const int FRAMES = 30; + + /** Frames kept to show it: the innermost, where the reader looks first. */ + private const int SHOWN_FRAMES = 10; private bool $installed = false; public function __construct( private readonly int $softLimit = 100, + private readonly ?Components $components = null, ) {} public function install(): void @@ -77,8 +84,20 @@ private function trace(mixed $data): mixed $data = is_array($data) ? $data : []; $count = is_object($wpdb) && isset($wpdb->queries) && is_array($wpdb->queries) ? count($wpdb->queries) : 0; + if ($count >= $this->softLimit) { + $data[self::KEY] = ['frames' => []]; + + return $data; + } + + // Attributed from the whole trace, shown from its innermost part: a + // page of WooCommerce runs a hundred queries, and full traces made + // most of the bar's weight + $frames = $this->frames(); + $data[self::KEY] = [ - 'frames' => $count < $this->softLimit ? $this->frames() : [], + 'frames' => array_slice($frames, 0, self::SHOWN_FRAMES), + 'component' => $this->components?->ofTrace($frames), ]; return $data; diff --git a/src/Support/Components.php b/src/Support/Components.php index 7afe7b0..7b037d1 100644 --- a/src/Support/Components.php +++ b/src/Support/Components.php @@ -51,7 +51,19 @@ public function of(string $file): string return 'unknown'; } - return $this->cache[$file] ??= $this->label($this->owner($file)); + if (isset($this->cache[$file])) { + return $this->cache[$file]; + } + + $label = $this->label($this->owner($file)); + + // Not kept while WordPress has not defined its directories yet: the + // same file belongs to a plugin once it has + if ($this->directories !== null) { + $this->cache[$file] = $label; + } + + return $label; } /** @@ -147,16 +159,15 @@ private function directories(): array $add(get_theme_root(), 'theme'); } - if (function_exists('base_path')) { + // Outside a Laravel application (a unit test), only WordPress's directories are known + try { $add(base_path('themes'), 'theme'); $add(base_path('Modules'), 'module'); $add(base_path('vendor/pollora'), 'pollora', ''); $add(base_path('vendor/laravel'), 'laravel', ''); $add(base_path('vendor'), 'vendor'); - } - - if (function_exists('app_path')) { $add(app_path(), 'app', ''); + } catch (\Throwable) { } if (defined('ABSPATH')) { @@ -165,6 +176,12 @@ private function directories(): array uksort($directories, static fn (string $a, string $b): int => strlen($b) <=> strlen($a)); - return $this->directories = $directories; + // The first queries run before WordPress defines where plugins and + // themes live; keep looking until it has + if (defined('WP_PLUGIN_DIR') && function_exists('get_theme_root')) { + $this->directories = $directories; + } + + return $directories; } } diff --git a/tests/Unit/Recording/QueryTracerTest.php b/tests/Unit/Recording/QueryTracerTest.php index a78c7b7..7d63e72 100644 --- a/tests/Unit/Recording/QueryTracerTest.php +++ b/tests/Unit/Recording/QueryTracerTest.php @@ -4,6 +4,7 @@ use Brain\Monkey\Filters; use Pollora\Debugbar\Recording\QueryTracer; +use Pollora\Debugbar\Support\Components; /** * @return array @@ -72,3 +73,13 @@ function tracerHooks(QueryTracer $tracer): ArrayObject expect($GLOBALS['wpdb']->queries[0][4][QueryTracer::KEY])->toMatchArray(['rows' => 3, 'error' => null]); }); + +it('attributes a query from its whole trace but keeps only the innermost frames to show', function (): void { + $components = new Components([dirname(__DIR__, 3).'/' => ['kind' => 'app', 'name' => '']]); + $hooks = tracerHooks(new QueryTracer(components: $components)); + + $data = $hooks['log_query_custom_data']([])[QueryTracer::KEY]; + + expect(count($data['frames']))->toBeLessThanOrEqual(10) + ->and($data['component'])->toBe('app'); +}); diff --git a/tests/Unit/Support/ComponentsTest.php b/tests/Unit/Support/ComponentsTest.php index 181912e..ed3fe6d 100644 --- a/tests/Unit/Support/ComponentsTest.php +++ b/tests/Unit/Support/ComponentsTest.php @@ -53,3 +53,11 @@ function components(): Components ['file' => '/site/public/content/plugins/acme/acme.php'], ]))->toBe('plugin: acme'); }); + +it('keeps looking for directories until WordPress has defined where plugins live', function (): void { + // No directories given and no WordPress here: nothing can be known, and nothing is kept + $components = new Components; + + expect($components->of('/anywhere/plugins/acme/acme.php'))->toBe('unknown') + ->and((new ReflectionProperty(Components::class, 'directories'))->getValue($components))->toBeNull(); +}); From 28217e928884f14d9a4da299947144c14769b17a Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 17:38:30 +0200 Subject: [PATCH 07/10] feat: hook timings, the bar in wp-admin, multisite (phase 3) - WP Hook timings tab (opt-in, options.wp_hooks.timings): each callback wrapped in place in $wp_filter under its own key, own and total time, slowest first; by-reference callbacks left alone - wp-admin: the bar printed with the admin footer scripts and stored, without Debugbar's Laravel-only tabs; the block editor's REST calls are listed in it - WP Request: admin page and screen; on a multisite, the site, the network, every site switch and a switch never restored - Hooks: a closure is named after a class only when written in it --- config/debugbar-pollora.php | 27 ++- src/CollectorRegistrar.php | 13 +- src/Collectors/WpHookTimingsCollector.php | 104 +++++++++ src/Collectors/WpHooksCollector.php | 9 +- src/Collectors/WpRequestCollector.php | 80 ++++++- src/DebugbarServiceProvider.php | 44 +++- src/Http/AdminBarRenderer.php | 89 ++++++++ src/Recording/HookTimer.php | 201 ++++++++++++++++++ src/Recording/SiteRecorder.php | 56 +++++ src/Recording/TimedCallback.php | 55 +++++ tests/Feature/AdminBarRendererTest.php | 43 ++++ tests/Fixtures/included-closure.php | 6 + .../Unit/Collectors/WpHooksCollectorTest.php | 22 +- .../WpRequestScreenAndSitesTest.php | 82 +++++++ tests/Unit/Recording/HookTimerTest.php | 141 ++++++++++++ tests/e2e/specs/debugbar.spec.ts | 32 +++ 16 files changed, 987 insertions(+), 17 deletions(-) create mode 100644 src/Collectors/WpHookTimingsCollector.php create mode 100644 src/Http/AdminBarRenderer.php create mode 100644 src/Recording/HookTimer.php create mode 100644 src/Recording/SiteRecorder.php create mode 100644 src/Recording/TimedCallback.php create mode 100644 tests/Feature/AdminBarRendererTest.php create mode 100644 tests/Fixtures/included-closure.php create mode 100644 tests/Unit/Collectors/WpRequestScreenAndSitesTest.php create mode 100644 tests/Unit/Recording/HookTimerTest.php diff --git a/config/debugbar-pollora.php b/config/debugbar-pollora.php index 3c88cba..dec3ba3 100644 --- a/config/debugbar-pollora.php +++ b/config/debugbar-pollora.php @@ -23,9 +23,11 @@ |-------------------------------------------------------------------------- | | pollora What answered the request, discovery, modules, theme, async - | wp_request Rewrite rule, query vars, main query, conditionals, templates + | wp_request Rewrite rule, query vars, main query, conditionals, templates, + | admin screen, multisite site and switches | wp_queries $wpdb queries (turns SAVEQUERIES on) - | wp_hooks Hooks that ran and the callbacks Pollora registered + | wp_hooks Hooks that ran and the callbacks Pollora registered; with + | options.wp_hooks.timings, a WP Hook timings tab | wp_timeline WordPress phases on Debugbar's timeline | wp_http HTTP calls made through wp_remote_* | wp_cache Object cache, transients set, OPcache @@ -54,6 +56,22 @@ 'bridges' => env('DEBUGBAR_POLLORA_COLLECTORS_BRIDGES', true), ], + /* + |-------------------------------------------------------------------------- + | wp-admin + |-------------------------------------------------------------------------- + | + | WordPress prints admin pages itself, so Laravel Debugbar never shows + | there on its own. The bar is printed with the admin footer scripts, + | without Debugbar's tabs about a request Laravel answered. + | + */ + + 'admin' => [ + 'enabled' => env('DEBUGBAR_POLLORA_ADMIN', true), + 'hidden_collectors' => ['route', 'views', 'session', 'livewire', 'inertia'], + ], + 'options' => [ 'wp_queries' => [ @@ -75,6 +93,11 @@ 'wp_hooks' => [ // Count filters too: an `all` listener runs on every apply_filters() 'count_filters' => env('DEBUGBAR_POLLORA_WP_HOOKS_COUNT_FILTERS', false), + // Time every callback (WP Hook timings tab); wraps each callback + // in $wp_filter and adds a timer to every call, so off by default + 'timings' => env('DEBUGBAR_POLLORA_WP_HOOKS_TIMINGS', false), + // How many callbacks the timings tab lists, slowest first + 'timings_limit' => (int) env('DEBUGBAR_POLLORA_WP_HOOKS_TIMINGS_LIMIT', 200), ], 'bridges' => [ diff --git a/src/CollectorRegistrar.php b/src/CollectorRegistrar.php index ca3fe36..1082352 100644 --- a/src/CollectorRegistrar.php +++ b/src/CollectorRegistrar.php @@ -15,6 +15,7 @@ use Pollora\Debugbar\Collectors\WpCacheCollector; use Pollora\Debugbar\Collectors\WpCapabilitiesCollector; use Pollora\Debugbar\Collectors\WpHooksCollector; +use Pollora\Debugbar\Collectors\WpHookTimingsCollector; use Pollora\Debugbar\Collectors\WpHttpCollector; use Pollora\Debugbar\Collectors\WpLanguagesCollector; use Pollora\Debugbar\Collectors\WpQueriesCollector; @@ -25,10 +26,12 @@ use Pollora\Debugbar\Recording\BlockRecorder; use Pollora\Debugbar\Recording\CacheRecorder; use Pollora\Debugbar\Recording\CapabilityRecorder; +use Pollora\Debugbar\Recording\HookTimer; use Pollora\Debugbar\Recording\HttpRecorder; use Pollora\Debugbar\Recording\LanguageRecorder; use Pollora\Debugbar\Recording\QueryTracer; use Pollora\Debugbar\Recording\RequestRecorder; +use Pollora\Debugbar\Recording\SiteRecorder; use Pollora\Debugbar\Support\Components; use Pollora\Hook\Domain\Contract\Action; use Pollora\Hook\Domain\Contract\Filter; @@ -117,7 +120,7 @@ private function builtIns(LaravelDebugbar $debugbar): array } if ($on('wp_request')) { - $collectors[] = new WpRequestCollector($this->recorder); + $collectors[] = new WpRequestCollector($this->recorder, $this->container->make(SiteRecorder::class), $this->container->make(Components::class)); } if ($on('wp_queries')) { @@ -134,6 +137,14 @@ private function builtIns(LaravelDebugbar $debugbar): array if ($on('wp_hooks')) { $collectors[] = new WpHooksCollector($this->recorder, $this->polloraHookServices()); + + if ($config->get('debugbar-pollora.options.wp_hooks.timings', false)) { + $collectors[] = new WpHookTimingsCollector( + $this->container->make(HookTimer::class), + $this->container->make(Components::class), + (int) $config->get('debugbar-pollora.options.wp_hooks.timings_limit', 200), + ); + } } $components = $this->container->make(Components::class); diff --git a/src/Collectors/WpHookTimingsCollector.php b/src/Collectors/WpHookTimingsCollector.php new file mode 100644 index 0000000..8f2975c --- /dev/null +++ b/src/Collectors/WpHookTimingsCollector.php @@ -0,0 +1,104 @@ + + */ + public function columns(): array + { + return [ + 'self' => 'Own time', + 'total' => 'Total', + 'calls' => 'Calls', + 'hook' => 'Hook', + 'component' => 'From', + ]; + } + + /** + * @return array + */ + protected function data(): array + { + $timings = $this->timer->timings(); + + usort($timings, static fn (array $a, array $b): int => $b['self'] <=> $a['self']); + + $rows = []; + + foreach (array_slice($timings, 0, max(1, $this->limit)) as $timing) { + $key = WpHooksCollector::describe($timing['callback']); + + // The same callback can run on several hooks + while (isset($rows[$key])) { + $key .= ' '; + } + + $rows[$key] = [ + 'self' => self::milliseconds($timing['self']), + 'total' => self::milliseconds($timing['total']), + 'calls' => $timing['calls'], + 'hook' => sprintf('%s @%d', $timing['hook'], $timing['priority']), + 'component' => $timing['file'] !== '' ? $this->components->of($timing['file']) : 'unknown', + ]; + } + + return $rows; + } + + private static function milliseconds(int $nanoseconds): string + { + return number_format($nanoseconds / 1_000_000, 2).' ms'; + } +} diff --git a/src/Collectors/WpHooksCollector.php b/src/Collectors/WpHooksCollector.php index 310b4b7..6ea7c17 100644 --- a/src/Collectors/WpHooksCollector.php +++ b/src/Collectors/WpHooksCollector.php @@ -7,6 +7,7 @@ use Pollora\Debugbar\Collector; use Pollora\Debugbar\Origin; use Pollora\Debugbar\Recording\RequestRecorder; +use Pollora\Debugbar\Recording\TimedCallback; use Pollora\Debugbar\Widget; /** @@ -76,6 +77,7 @@ public function columns(): array public static function describe(mixed $callback): string { return match (true) { + $callback instanceof TimedCallback => self::describe($callback->callback), is_string($callback) => $callback, is_array($callback) && isset($callback[0], $callback[1]) => (is_object($callback[0]) ? $callback[0]::class : (string) $callback[0]).'::'.(string) $callback[1], $callback instanceof \Closure => self::describeClosure($callback), @@ -123,7 +125,12 @@ protected function data(): array private static function describeClosure(\Closure $closure): string { $reflection = new \ReflectionFunction($closure); - $scope = $reflection->getClosureScopeClass()?->getName(); + $scopeClass = $reflection->getClosureScopeClass(); + + // A file included from a method (a mu-plugin WordPress loads from + // Pollora's Bootstrap) gives its closures that class's scope: name + // the class only when the closure is written in it + $scope = $scopeClass !== null && $scopeClass->getFileName() === $reflection->getFileName() ? $scopeClass->getName() : null; if (! str_contains($reflection->getName(), '{closure')) { return $scope !== null ? "{$scope}::{$reflection->getName()}" : $reflection->getName(); diff --git a/src/Collectors/WpRequestCollector.php b/src/Collectors/WpRequestCollector.php index 347bfe0..5338544 100644 --- a/src/Collectors/WpRequestCollector.php +++ b/src/Collectors/WpRequestCollector.php @@ -7,10 +7,13 @@ use Pollora\Debugbar\Collector; use Pollora\Debugbar\Origin; use Pollora\Debugbar\Recording\RequestRecorder; +use Pollora\Debugbar\Recording\SiteRecorder; +use Pollora\Debugbar\Support\Components; /** * How WordPress understood the request: rewrite rule, query, queried object, - * conditionals and the template hierarchy it built. + * conditionals and the template hierarchy it built; in wp-admin, the screen; + * on a multisite, the site and the switches between sites. */ final class WpRequestCollector extends Collector { @@ -30,6 +33,8 @@ final class WpRequestCollector extends Collector public function __construct( private readonly RequestRecorder $recorder, + private readonly ?SiteRecorder $sites = null, + private readonly ?Components $components = null, ) {} public function getName(): string @@ -96,6 +101,79 @@ protected function data(): array $data['Template'] = $this->recorder->template() !== null ? $this->relativePath($this->recorder->template()) : null; $data['Template hierarchy'] = $this->recorder->hierarchies() !== [] ? $this->recorder->hierarchies() : null; + return [...$data, ...$this->adminScreen(), ...$this->multisite()]; + } + + /** + * The admin page and screen, as Query Monitor's Admin panel shows them. + * + * @return array + */ + private function adminScreen(): array + { + global $pagenow, $hook_suffix; + + // $pagenow is set once WordPress has parsed the URL; before that, + // is_admin() has nothing reliable to say + if (! is_string($pagenow) || ! function_exists('is_admin') || ! is_admin() || ! function_exists('get_current_screen')) { + return []; + } + + $screen = get_current_screen(); + + return array_filter([ + 'Admin page' => $pagenow, + 'Hook suffix' => is_string($hook_suffix) && $hook_suffix !== '' ? $hook_suffix : null, + 'Screen' => $screen instanceof \WP_Screen ? array_filter([ + 'id' => $screen->id, + 'base' => $screen->base, + 'post type' => $screen->post_type, + 'taxonomy' => $screen->taxonomy, + 'block editor' => $screen->is_block_editor() ? 'yes' : null, + ], static fn (mixed $value): bool => $value !== '' && $value !== null) : null, + ], static fn (mixed $value): bool => $value !== null); + } + + /** + * The site and network that answered, and each switch between sites. + * + * @return array + */ + private function multisite(): array + { + // ms-settings.php sets the network; a single site never has one + if (! is_object($GLOBALS['current_site'] ?? null) || ! function_exists('is_multisite') || ! is_multisite()) { + return []; + } + + $switched = function_exists('ms_is_switched') && ms_is_switched(); + // While switched, the site that answered is the first one switched away from + $stack = $GLOBALS['_wp_switched_stack'] ?? []; + $site = $switched && is_array($stack) && isset($stack[0]) ? (int) $stack[0] : get_current_blog_id(); + + $data = [ + 'Site' => sprintf('#%d%s', $site, is_main_site($site) ? ' (main site)' : ''), + 'Network' => function_exists('get_current_network_id') ? '#'.get_current_network_id() : null, + ]; + + $switches = []; + + foreach ($this->sites?->switches() ?? [] as $switch) { + $switches[] = sprintf( + '%s #%d → #%d%s', + $switch['context'], + $switch['from'], + $switch['to'], + $this->components instanceof Components && $switch['frames'] !== [] ? ' ('.$this->components->ofTrace($switch['frames']).')' : '', + ); + } + + $data['Site switches'] = $switches !== [] ? $switches : 'none'; + + if ($switched) { + $data['Still switched'] = sprintf('yes, to #%d: restore_current_blog() was not called for every switch_to_blog()', get_current_blog_id()); + } + return $data; } diff --git a/src/DebugbarServiceProvider.php b/src/DebugbarServiceProvider.php index 2028be9..a336e76 100644 --- a/src/DebugbarServiceProvider.php +++ b/src/DebugbarServiceProvider.php @@ -9,16 +9,19 @@ use Illuminate\Support\ServiceProvider; use Pollora\BlockBinding\Domain\Events\BindingResolved; use Pollora\Debugbar\Bridges\MessageBridge; +use Pollora\Debugbar\Http\AdminBarRenderer; use Pollora\Debugbar\Http\DoctorController; use Pollora\Debugbar\Http\WordPressExitResponder; use Pollora\Debugbar\Recording\AsyncRecorder; use Pollora\Debugbar\Recording\BlockRecorder; use Pollora\Debugbar\Recording\CacheRecorder; use Pollora\Debugbar\Recording\CapabilityRecorder; +use Pollora\Debugbar\Recording\HookTimer; use Pollora\Debugbar\Recording\HttpRecorder; use Pollora\Debugbar\Recording\LanguageRecorder; use Pollora\Debugbar\Recording\QueryTracer; use Pollora\Debugbar\Recording\RequestRecorder; +use Pollora\Debugbar\Recording\SiteRecorder; use Pollora\Debugbar\Support\Components; use Pollora\WordPress\Events\WordPressBooting; @@ -49,6 +52,8 @@ public function register(): void $this->app->singleton(CapabilityRecorder::class, fn ($app): CapabilityRecorder => new CapabilityRecorder((bool) $app->make('config')->get('debugbar-pollora.options.wp_capabilities.backtrace', false))); $this->app->singleton(BlockRecorder::class); $this->app->singleton(AsyncRecorder::class); + $this->app->singleton(HookTimer::class); + $this->app->singleton(SiteRecorder::class); if (! Activation::shouldRun($this->app)) { return; @@ -62,13 +67,25 @@ public function register(): void $on = static fn (string $collector): bool => (bool) $config->get("debugbar-pollora.collectors.{$collector}", true); + $admin = $config->get('debugbar-pollora.admin.enabled', true) && AdminBarRenderer::isAdminPage(); + + if ($admin) { + // Debugbar adds its tabs while it boots; by then every provider has + // registered and its config is merged + $this->app->booting(function () use ($config): void { + foreach ((array) $config->get('debugbar-pollora.admin.hidden_collectors', AdminBarRenderer::LARAVEL_ONLY_COLLECTORS) as $collector) { + $config->set("debugbar.collectors.{$collector}", false); + } + }); + } + if ($on('wp_blocks') && class_exists(BindingResolved::class)) { $this->app->make('events')->listen(BindingResolved::class, function (BindingResolved $binding): void { $this->app->make(BlockRecorder::class)->recordBinding($binding); }); } - $this->app->make('events')->listen(WordPressBooting::class, function () use ($config, $on): void { + $this->app->make('events')->listen(WordPressBooting::class, function () use ($config, $on, $admin): void { $this->app->make(RequestRecorder::class)->install( countAllHooks: (bool) $config->get('debugbar-pollora.options.wp_hooks.count_filters', false), ); @@ -81,6 +98,8 @@ public function register(): void CapabilityRecorder::class => $on('wp_capabilities'), BlockRecorder::class => $on('wp_blocks'), AsyncRecorder::class => $on('pollora'), + HookTimer::class => $on('wp_hooks') && $config->get('debugbar-pollora.options.wp_hooks.timings', false), + SiteRecorder::class => $on('wp_request'), ]; foreach (array_keys(array_filter($recorders)) as $recorder) { @@ -93,16 +112,19 @@ public function register(): void ); } - (new WordPressExitResponder( - $this->collectingDebugbar(...), - function (LaravelDebugbar $debugbar): void { - // WordPress can exit before the booted callbacks that index - // route names have run, and Debugbar's collectors link to - // its own named routes - $this->app->make('router')->getRoutes()->refreshNameLookups(); - $this->app->make(CollectorRegistrar::class)->register($debugbar); - }, - ))->install(); + $prepare = function (LaravelDebugbar $debugbar): void { + // WordPress can exit before the booted callbacks that index + // route names have run, and Debugbar's collectors link to + // its own named routes + $this->app->make('router')->getRoutes()->refreshNameLookups(); + $this->app->make(CollectorRegistrar::class)->register($debugbar); + }; + + (new WordPressExitResponder($this->collectingDebugbar(...), $prepare))->install(); + + if ($admin) { + (new AdminBarRenderer($this->collectingDebugbar(...), $prepare))->install(); + } }); } diff --git a/src/Http/AdminBarRenderer.php b/src/Http/AdminBarRenderer.php new file mode 100644 index 0000000..833407c --- /dev/null +++ b/src/Http/AdminBarRenderer.php @@ -0,0 +1,89 @@ +render(...), PHP_INT_MAX, 0); + } + + public function render(): void + { + if ($this->rendered || ! ($this->isAdminPage ?? self::isAdminPage(...))()) { + return; + } + + $debugbar = ($this->debugbar)(); + + if (! $debugbar instanceof LaravelDebugbar) { + return; + } + + $this->rendered = true; + + // A debugging tool must never break the page it watches + try { + ($this->prepare)($debugbar); + $renderer = $debugbar->getJavascriptRenderer(); + $bar = "\n".$renderer->renderHead().$renderer->render(); + } catch (\Throwable $throwable) { + report($throwable); + + return; + } + + echo $bar; + } +} diff --git a/src/Recording/HookTimer.php b/src/Recording/HookTimer.php new file mode 100644 index 0000000..df3e94e --- /dev/null +++ b/src/Recording/HookTimer.php @@ -0,0 +1,201 @@ + + */ + private array $timings = []; + + /** + * Callback ids already looked at, and whether they can be timed. + * + * @var array + */ + private array $decisions = []; + + /** + * Start time and time spent in nested callbacks, innermost last, in nanoseconds. + * + * @var list + */ + private array $stack = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_action')) { + return; + } + + $this->installed = true; + + add_action('all', function (mixed $hook): void { + if (is_string($hook)) { + $this->wrap($hook); + } + }, PHP_INT_MIN, 1); + } + + public function isInstalled(): bool + { + return $this->installed; + } + + /** + * Put a timer around every callback of a hook not timed yet. + */ + public function wrap(string $hook): void + { + global $wp_filter; + + $wpHook = is_array($wp_filter) ? ($wp_filter[$hook] ?? null) : null; + + if (! is_object($wpHook) || ! isset($wpHook->callbacks) || ! is_array($wpHook->callbacks)) { + return; + } + + foreach ($wpHook->callbacks as $priority => $callbacks) { + foreach ((array) $callbacks as $id => $entry) { + $callback = $entry['function'] ?? null; + + if ($callback === null || $callback instanceof TimedCallback) { + continue; + } + + $decision = $this->decisions[(string) $id] ??= $this->decide($callback); + + if ($decision === false) { + continue; + } + + $key = $hook.'|'.$priority.'|'.$id; + + $this->timings[$key] ??= [ + 'hook' => $hook, + 'priority' => (int) $priority, + 'callback' => $callback, + 'file' => $decision['file'], + 'calls' => 0, + 'total' => 0, + 'self' => 0, + 'max' => 0, + ]; + + $wpHook->callbacks[$priority][$id]['function'] = new TimedCallback($callback, $this, $key); + } + } + } + + /** + * A callback starts. + */ + public function enter(): void + { + $this->stack[] = [hrtime(true), 0]; + } + + /** + * The callback that started last ends. + */ + public function leave(string $key): void + { + $frame = array_pop($this->stack); + + if ($frame === null) { + return; + } + + $elapsed = hrtime(true) - $frame[0]; + $last = array_key_last($this->stack); + + if ($last !== null) { + $this->stack[$last][1] += $elapsed; + } + + if (! isset($this->timings[$key])) { + return; + } + + $timing = &$this->timings[$key]; + $timing['calls']++; + $timing['total'] += $elapsed; + $timing['self'] += $elapsed - $frame[1]; + $timing['max'] = max($timing['max'], $elapsed); + } + + /** + * The callbacks that ran, times in nanoseconds. + * + * @return list + */ + public function timings(): array + { + return array_values(array_filter($this->timings, static fn (array $timing): bool => $timing['calls'] > 0)); + } + + public function reset(): void + { + $this->timings = []; + $this->stack = []; + } + + /** + * Whether a callback can be timed, and the file it was written in. + * + * @return array{file: string}|false + */ + private function decide(mixed $callback): array|false + { + try { + $reflection = match (true) { + $callback instanceof \Closure => new \ReflectionFunction($callback), + is_string($callback) && str_contains($callback, '::') => new \ReflectionMethod($callback), + is_string($callback) => new \ReflectionFunction($callback), + is_array($callback) && isset($callback[0], $callback[1]) && (is_object($callback[0]) || is_string($callback[0])) && is_string($callback[1]) => new \ReflectionMethod($callback[0], $callback[1]), + is_object($callback) && method_exists($callback, '__invoke') => new \ReflectionMethod($callback, '__invoke'), + default => null, + }; + } catch (\ReflectionException) { + // Not callable yet, or answered by __call(): leave it alone + return false; + } + + if ($reflection === null) { + return false; + } + + foreach ($reflection->getParameters() as $parameter) { + if ($parameter->isPassedByReference()) { + return false; + } + } + + $class = $reflection instanceof \ReflectionMethod ? $reflection->getDeclaringClass()->getName() : $reflection->getClosureScopeClass()?->getName(); + + if ($class !== null && str_starts_with($class, 'Pollora\\Debugbar\\')) { + return false; + } + + return ['file' => (string) $reflection->getFileName()]; + } +} diff --git a/src/Recording/SiteRecorder.php b/src/Recording/SiteRecorder.php new file mode 100644 index 0000000..5eaf574 --- /dev/null +++ b/src/Recording/SiteRecorder.php @@ -0,0 +1,56 @@ +}> + */ + private array $switches = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_action')) { + return; + } + + $this->installed = true; + + add_action('switch_blog', function (mixed $to, mixed $from, mixed $context = 'switch'): void { + $this->switches[] = [ + 'from' => (int) $from, + 'to' => (int) $to, + 'context' => is_string($context) ? $context : 'switch', + 'frames' => array_values(array_filter( + array_map(static fn (array $frame): array => ['file' => $frame['file'] ?? ''], debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 15)), + static fn (array $frame): bool => $frame['file'] !== '', + )), + ]; + }, PHP_INT_MAX, 3); + } + + /** + * @return list}> + */ + public function switches(): array + { + return $this->switches; + } + + public function reset(): void + { + $this->switches = []; + } +} diff --git a/src/Recording/TimedCallback.php b/src/Recording/TimedCallback.php new file mode 100644 index 0000000..96297f1 --- /dev/null +++ b/src/Recording/TimedCallback.php @@ -0,0 +1,55 @@ + + */ +final class TimedCallback implements \ArrayAccess +{ + public function __construct( + public readonly mixed $callback, + private readonly HookTimer $timer, + private readonly string $key, + ) {} + + public function __invoke(mixed ...$args): mixed + { + $this->timer->enter(); + + try { + return ($this->callback)(...$args); + } finally { + $this->timer->leave($this->key); + } + } + + public function offsetExists(mixed $offset): bool + { + return is_array($this->callback) && isset($this->callback[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + return is_array($this->callback) ? ($this->callback[$offset] ?? null) : null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new \LogicException('A timed hook callback cannot be changed.'); + } + + public function offsetUnset(mixed $offset): void + { + throw new \LogicException('A timed hook callback cannot be changed.'); + } +} diff --git a/tests/Feature/AdminBarRendererTest.php b/tests/Feature/AdminBarRendererTest.php new file mode 100644 index 0000000..ca1dba1 --- /dev/null +++ b/tests/Feature/AdminBarRendererTest.php @@ -0,0 +1,43 @@ +app->make('router'); + $router->get('_debugbar/assets', fn (): string => '')->name('debugbar.assets'); + $router->get('_debugbar/open', fn (): string => '')->name('debugbar.openhandler'); + $router->getRoutes()->refreshNameLookups(); + $debugbar = $this->collectingDebugbar(); + $prepared = 0; + $renderer = new AdminBarRenderer(fn () => $debugbar, function () use (&$prepared): void { + $prepared++; + }, fn (): bool => true); + + ob_start(); + $renderer->render(); + $renderer->render(); + $output = (string) ob_get_clean(); + + expect($prepared)->toBe(1) + ->and(substr_count($output, 'Laravel Debugbar Widget'))->toBe(1) + ->and($debugbar->getStorage()->saved)->toHaveKey($debugbar->getCurrentRequestId()); +}); + +it('prints nothing outside admin pages', function (): void { + $debugbar = $this->collectingDebugbar(); + + ob_start(); + (new AdminBarRenderer(fn () => $debugbar, fn () => null, fn (): bool => false))->render(); + + expect((string) ob_get_clean())->toBe(''); +}); + +it('prints nothing when the bar is not collecting', function (): void { + ob_start(); + (new AdminBarRenderer(fn () => null, fn () => null, fn (): bool => true))->render(); + + expect((string) ob_get_clean())->toBe(''); +}); diff --git a/tests/Fixtures/included-closure.php b/tests/Fixtures/included-closure.php new file mode 100644 index 0000000..0d5ede5 --- /dev/null +++ b/tests/Fixtures/included-closure.php @@ -0,0 +1,6 @@ + null; diff --git a/tests/Unit/Collectors/WpHooksCollectorTest.php b/tests/Unit/Collectors/WpHooksCollectorTest.php index ea7bc07..723e48b 100644 --- a/tests/Unit/Collectors/WpHooksCollectorTest.php +++ b/tests/Unit/Collectors/WpHooksCollectorTest.php @@ -39,9 +39,29 @@ public function all(): array ->and($rows['wp_head']['pollora'])->toBe(''); }); +final class WpHooksClosureOwner +{ + public function closure(): Closure + { + return fn (): null => null; + } +} + it('describes callbacks the way a reader recognises them', function (): void { expect(WpHooksCollector::describe('wp_head'))->toBe('wp_head') ->and(WpHooksCollector::describe(['Acme\\Cart', 'boot']))->toBe('Acme\\Cart::boot') - ->and(WpHooksCollector::describe(fn (): null => null))->toMatch('/^closure in P\\\\Tests\\\\Unit\\\\Collectors\\\\WpHooksCollectorTest\\S* \\(WpHooksCollectorTest\\.php:\\d+\\)$/') + ->and(WpHooksCollector::describe((new WpHooksClosureOwner)->closure()))->toMatch('/^closure in WpHooksClosureOwner \\(WpHooksCollectorTest\\.php:\\d+\\)$/') ->and(WpHooksCollector::describe((new ArrayObject)->count(...)))->toBe('ArrayObject::count'); }); + +it('does not name the class a closure was merely included from', function (): void { + $loader = new class + { + public function load(): Closure + { + return require __DIR__.'/../../Fixtures/included-closure.php'; + } + }; + + expect(WpHooksCollector::describe($loader->load()))->toMatch('/^closure \\(included-closure\\.php:\\d+\\)$/'); +}); diff --git a/tests/Unit/Collectors/WpRequestScreenAndSitesTest.php b/tests/Unit/Collectors/WpRequestScreenAndSitesTest.php new file mode 100644 index 0000000..e9cb71e --- /dev/null +++ b/tests/Unit/Collectors/WpRequestScreenAndSitesTest.php @@ -0,0 +1,82 @@ +setDataFormatter(new DataFormatter) + ->collect()['data']; +} + +afterEach(function (): void { + unset($GLOBALS['pagenow'], $GLOBALS['hook_suffix'], $GLOBALS['current_site'], $GLOBALS['_wp_switched_stack']); +}); + +it('shows the admin page and screen in wp-admin', function (): void { + $GLOBALS['pagenow'] = 'edit.php'; + $GLOBALS['hook_suffix'] = 'edit.php'; + Functions\when('is_admin')->justReturn(true); + Functions\when('get_current_screen')->justReturn(new WP_Screen); + Functions\when('get_queried_object')->justReturn(null); + + $data = requestDataWith(); + + expect($data['Admin page'])->toBe('edit.php') + ->and($data['Screen'])->toContain('edit-post') + ->and($data)->not->toHaveKey('Site'); +}); + +it('shows the site and every switch between sites, and a switch never restored', function (): void { + $switch = null; + Actions\expectAdded('switch_blog')->once()->whenHappen(function (callable $callback) use (&$switch): void { + $switch = $callback; + }); + $sites = new SiteRecorder; + $sites->install(); + $switch(2, 1, 'switch'); + $switch(1, 2, 'restore'); + $switch(3, 1, 'switch'); + + $GLOBALS['current_site'] = (object) ['id' => 1]; + $GLOBALS['_wp_switched_stack'] = [1]; + Functions\when('get_queried_object')->justReturn(null); + Functions\when('is_multisite')->justReturn(true); + Functions\when('get_current_blog_id')->justReturn(3); + Functions\when('is_main_site')->alias(fn (int $site): bool => $site === 1); + Functions\when('get_current_network_id')->justReturn(1); + Functions\when('ms_is_switched')->justReturn(true); + + $data = requestDataWith($sites); + + expect($data['Site'])->toBe('#1 (main site)') + ->and($data['Still switched'])->toStartWith('yes, to #3') + ->and($data['Site switches'])->toContain('switch #1 → #2') + ->and($data['Site switches'])->toContain('restore #2 → #1'); +}); diff --git a/tests/Unit/Recording/HookTimerTest.php b/tests/Unit/Recording/HookTimerTest.php new file mode 100644 index 0000000..91d66dd --- /dev/null +++ b/tests/Unit/Recording/HookTimerTest.php @@ -0,0 +1,141 @@ +> $callbacks Priority => [id => callback] + */ +function hookWith(string $name, array $callbacks): void +{ + $entries = []; + + foreach ($callbacks as $priority => $byId) { + foreach ($byId as $id => $callback) { + $entries[$priority][$id] = ['function' => $callback, 'accepted_args' => 1]; + } + } + + $GLOBALS['wp_filter'][$name] = (object) ['callbacks' => $entries]; +} + +function callbackOf(string $hook, int $priority, string $id): mixed +{ + return $GLOBALS['wp_filter'][$hook]->callbacks[$priority][$id]['function']; +} + +final class HookTimerFixture +{ + public function title(string $title): string + { + return strtoupper($title); + } + + public function byReference(array &$items): void + { + $items[] = 'added'; + } +} + +afterEach(function (): void { + unset($GLOBALS['wp_filter']); +}); + +it('times each callback under the key WordPress gave it, and returns what it returns', function (): void { + hookWith('the_title', [10 => ['fixture_title' => [new HookTimerFixture, 'title']]]); + $timer = new HookTimer; + + $timer->wrap('the_title'); + $callback = callbackOf('the_title', 10, 'fixture_title'); + + expect($callback)->toBeInstanceOf(TimedCallback::class) + ->and($callback('hello'))->toBe('HELLO') + ->and($timer->timings())->toHaveCount(1) + ->and($timer->timings()[0]['calls'])->toBe(1) + ->and($timer->timings()[0]['hook'])->toBe('the_title'); +}); + +it('keeps an array callback readable where code looks inside $wp_filter', function (): void { + $fixture = new HookTimerFixture; + hookWith('the_title', [10 => ['fixture_title' => [$fixture, 'title']]]); + + (new HookTimer)->wrap('the_title'); + $callback = callbackOf('the_title', 10, 'fixture_title'); + + expect($callback[0])->toBe($fixture) + ->and($callback[1])->toBe('title'); +}); + +it('counts the time spent in nested hooks in the total, not in the own time', function (): void { + $timer = new HookTimer; + hookWith('inner', [10 => ['inner_cb' => function (): void { + usleep(6000); + }]]); + hookWith('outer', [10 => ['outer_cb' => function (): void { + usleep(1000); + callbackOf('inner', 10, 'inner_cb')(); + }]]); + + $timer->wrap('inner'); + $timer->wrap('outer'); + callbackOf('outer', 10, 'outer_cb')(); + + $byHook = array_column($timer->timings(), null, 'hook'); + + expect($byHook['outer']['total'])->toBeGreaterThan(6_000_000) + ->and($byHook['outer']['self'])->toBeLessThan(6_000_000) + ->and($byHook['inner']['self'])->toBeGreaterThan(6_000_000); +}); + +it('leaves callbacks taking parameters by reference as they are', function (): void { + $fixture = new HookTimerFixture; + hookWith('collect', [10 => ['by_ref' => [$fixture, 'byReference']]]); + + (new HookTimer)->wrap('collect'); + + expect(callbackOf('collect', 10, 'by_ref'))->toBe([$fixture, 'byReference']); +}); + +it('times a callback added again in the same row', function (): void { + $title = [new HookTimerFixture, 'title']; + hookWith('the_title', [10 => ['fixture_title' => $title]]); + $timer = new HookTimer; + + $timer->wrap('the_title'); + callbackOf('the_title', 10, 'fixture_title')('a'); + + // add_filter() with the same callback writes the raw callback back under its key + $GLOBALS['wp_filter']['the_title']->callbacks[10]['fixture_title']['function'] = $title; + $timer->wrap('the_title'); + callbackOf('the_title', 10, 'fixture_title')('b'); + + expect($timer->timings())->toHaveCount(1) + ->and($timer->timings()[0]['calls'])->toBe(2); +}); + +it('lists the slowest callbacks first, up to the limit', function (): void { + $timer = new HookTimer; + hookWith('fast', [10 => ['fast_cb' => 'strtoupper']]); + hookWith('slow', [5 => ['slow_cb' => function (): void { + usleep(3000); + }]]); + + $timer->wrap('fast'); + $timer->wrap('slow'); + callbackOf('fast', 10, 'fast_cb')('a'); + callbackOf('slow', 5, 'slow_cb')(); + + $rows = (new WpHookTimingsCollector($timer, new Components([]), limit: 1)) + ->setDataFormatter(new DataFormatter) + ->collect()['data']['data']; + + expect($rows)->toHaveCount(1) + ->and(array_values($rows)[0]['hook'])->toBe('slow @5'); +}); diff --git a/tests/e2e/specs/debugbar.spec.ts b/tests/e2e/specs/debugbar.spec.ts index 5fc935a..267d98d 100644 --- a/tests/e2e/specs/debugbar.spec.ts +++ b/tests/e2e/specs/debugbar.spec.ts @@ -128,3 +128,35 @@ test('runs the doctor on demand from its tab', async ({ page }) => { await expect(panel.locator('tr').first()).toBeVisible(); await expect(panel).toContainText(/\d+ (ok|warning|error)/); }); + +async function logIn(page: Page): Promise { + await page.goto('cms/wp-login.php'); + await page.fill('#user_login', process.env.E2E_ADMIN_USER ?? 'admin'); + await page.fill('#user_pass', process.env.E2E_ADMIN_PASSWORD ?? 'pollora-ci-password'); + await page.click('#wp-submit'); + await page.waitForURL(/wp-admin/); +} + +test('shows the bar on wp-admin pages, with the admin screen and without Laravel-only tabs', async ({ page }) => { + await logIn(page); + await page.goto('cms/wp-admin/edit.php'); + const bar = await debugbar(page); + // The login redirect is stacked before this page's own request + const request = Object.values(bar.datasets).find((dataset) => String(dataset.__meta?.uri ?? '').includes('edit.php'))?.wp_request.data; + + expect(bar.controls).toHaveProperty('wp_request'); + expect(bar.controls).toHaveProperty('wp_queries'); + expect(bar.controls).not.toHaveProperty('route'); + expect(bar.controls).not.toHaveProperty('views'); + expect(request['Admin page']).toBe('edit.php'); + expect(JSON.stringify(request['Screen'])).toContain('edit-post'); +}); + +test('lists the REST calls the block editor makes in the bar of its admin page', async ({ page }) => { + await logIn(page); + await page.goto('cms/wp-admin/post-new.php'); + await debugbar(page); + + await expect.poll(async () => page.evaluate(() => Object.keys((window as any).phpdebugbar.datasets).length), { timeout: 15_000 }) + .toBeGreaterThan(1); +}); From 824cdea13dd5e766ae4c9e01f13ff44572336fd1 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 18:00:43 +0200 Subject: [PATCH 08/10] fix: no second bar inside iframes such as the Site Editor canvas The request is still stored; set DEBUGBAR_POLLORA_IFRAMES=true to print the bar in frames too. --- config/debugbar-pollora.php | 13 +++++++++++++ src/Activation.php | 14 ++++++++++++++ src/DebugbarServiceProvider.php | 9 +++++++++ tests/Feature/ActivationTest.php | 10 ++++++++++ tests/e2e/specs/debugbar.spec.ts | 10 ++++++++++ 5 files changed, 56 insertions(+) diff --git a/config/debugbar-pollora.php b/config/debugbar-pollora.php index dec3ba3..c939bb9 100644 --- a/config/debugbar-pollora.php +++ b/config/debugbar-pollora.php @@ -56,6 +56,19 @@ 'bridges' => env('DEBUGBAR_POLLORA_COLLECTORS_BRIDGES', true), ], + /* + |-------------------------------------------------------------------------- + | Pages inside an iframe + |-------------------------------------------------------------------------- + | + | The Site Editor and the Customizer show the front end in an iframe. Those + | requests are still stored (open them from the bar's request list), but + | no bar is printed inside the frame unless this is true. + | + */ + + 'iframes' => env('DEBUGBAR_POLLORA_IFRAMES', false), + /* |-------------------------------------------------------------------------- | wp-admin diff --git a/src/Activation.php b/src/Activation.php index 97d9f7f..913cad0 100644 --- a/src/Activation.php +++ b/src/Activation.php @@ -6,6 +6,7 @@ use Fruitcake\LaravelDebugbar\LaravelDebugbar; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Http\Request; /** * Whether Pollora's tabs run for this request. @@ -42,4 +43,17 @@ public static function shouldRun(Application $app): bool return (bool) ($enabled ?? $config->get('app.debug')); } + + /** + * Whether the page is loaded inside an iframe: the Site Editor canvas, + * the Customizer preview, an embed. + * + * Browsers say so with `Sec-Fetch-Dest`; `wp_site_preview` covers the + * Site Editor in a browser that does not send it. + */ + public static function isFramed(Request $request): bool + { + return $request->headers->get('Sec-Fetch-Dest') === 'iframe' + || $request->query->has('wp_site_preview'); + } } diff --git a/src/DebugbarServiceProvider.php b/src/DebugbarServiceProvider.php index a336e76..9efec73 100644 --- a/src/DebugbarServiceProvider.php +++ b/src/DebugbarServiceProvider.php @@ -69,6 +69,15 @@ public function register(): void $admin = $config->get('debugbar-pollora.admin.enabled', true) && AdminBarRenderer::isAdminPage(); + $this->app->booting(function () use ($config): void { + // The Site Editor and the Customizer show the front end in an + // iframe: the request is still stored, but a second bar inside + // the canvas would cover the page being edited + if (! $config->get('debugbar-pollora.iframes', false) && $this->app->bound('request') && Activation::isFramed($this->app->make('request'))) { + $config->set('debugbar.inject', false); + } + }); + if ($admin) { // Debugbar adds its tabs while it boots; by then every provider has // registered and its config is merged diff --git a/tests/Feature/ActivationTest.php b/tests/Feature/ActivationTest.php index 34ec564..3caf395 100644 --- a/tests/Feature/ActivationTest.php +++ b/tests/Feature/ActivationTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Illuminate\Foundation\Application; +use Illuminate\Http\Request; use Pollora\Debugbar\Activation; /** @@ -49,3 +50,12 @@ function servingTheWeb(Application $app): void expect(Activation::shouldRun($this->app))->toBeFalse(); }); + +it('tells a page loaded in an iframe from a page of its own', function (): void { + $framed = Request::create('/'); + $framed->headers->set('Sec-Fetch-Dest', 'iframe'); + + expect(Activation::isFramed($framed))->toBeTrue() + ->and(Activation::isFramed(Request::create('/?wp_site_preview=1')))->toBeTrue() + ->and(Activation::isFramed(Request::create('/')))->toBeFalse(); +}); diff --git a/tests/e2e/specs/debugbar.spec.ts b/tests/e2e/specs/debugbar.spec.ts index 267d98d..adcba8d 100644 --- a/tests/e2e/specs/debugbar.spec.ts +++ b/tests/e2e/specs/debugbar.spec.ts @@ -160,3 +160,13 @@ test('lists the REST calls the block editor makes in the bar of its admin page', await expect.poll(async () => page.evaluate(() => Object.keys((window as any).phpdebugbar.datasets).length), { timeout: 15_000 }) .toBeGreaterThan(1); }); + +test('prints no second bar inside the Site Editor canvas', async ({ page }) => { + await logIn(page); + await page.goto('cms/wp-admin/site-editor.php'); + await debugbar(page); + + const canvas = page.frameLocator('iframe[name="editor-canvas"], iframe').first(); + await expect(canvas.locator('body')).toBeVisible({ timeout: 15_000 }); + await expect(canvas.locator('.phpdebugbar')).toHaveCount(0); +}); From 53bb12bcae7411d3f9dddb3318bbdd0a403155da Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 18:21:27 +0200 Subject: [PATCH 09/10] ci: take the framework from release/v13.35.3, where #442 is merged --- .github/workflows/tests.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9ed9ef3..66e5254 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,8 +27,8 @@ jobs: extensions: dom, curl, libxml, mbstring, zip, pdo_sqlite coverage: none - - name: Use the unreleased framework (until v13.35.3 is tagged; feature branch until framework#442 is merged) - run: composer require --no-update --no-interaction "pollora/framework:dev-feat/debugbar-extension-points as 13.35.3" + - name: Use the unreleased framework (release branch until v13.35.3 is tagged) + run: composer require --no-update --no-interaction "pollora/framework:dev-release/v13.35.3 as 13.35.3" - name: Install dependencies run: composer update --prefer-stable --prefer-dist --no-interaction @@ -50,8 +50,8 @@ jobs: extensions: dom, curl, libxml, mbstring, zip coverage: none - - name: Use the unreleased framework (until v13.35.3 is tagged; feature branch until framework#442 is merged) - run: composer require --no-update --no-interaction "pollora/framework:dev-feat/debugbar-extension-points as 13.35.3" + - name: Use the unreleased framework (release branch until v13.35.3 is tagged) + run: composer require --no-update --no-interaction "pollora/framework:dev-release/v13.35.3 as 13.35.3" - name: Install dependencies run: composer update --prefer-stable --prefer-dist --no-interaction @@ -113,11 +113,11 @@ jobs: -e 's/^# DB_PASSWORD=.*/DB_PASSWORD=db/' \ .env - # Until v13.35.3 is tagged, the framework comes from framework#442's branch. + # Until v13.35.3 is tagged, the framework comes from its release branch. - name: Install the skeleton with this package checkout run: | ddev composer config repositories.debugbar '{"type": "path", "url": "packages/debugbar", "options": {"symlink": false, "versions": {"pollora/debugbar": "0.1.0"}}}' - ddev composer require "pollora/framework:dev-feat/debugbar-extension-points as 13.35.3" --with-all-dependencies --no-interaction --no-progress --no-scripts + ddev composer require "pollora/framework:dev-release/v13.35.3 as 13.35.3" --with-all-dependencies --no-interaction --no-progress --no-scripts ddev composer require --dev pollora/debugbar:0.1.0 --with-all-dependencies --no-interaction --no-progress - name: Generate application key From 498c2bb1e1465eddc24d5832891537ef1504d6b1 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 9 Oct 2026 14:33:52 +0200 Subject: [PATCH 10/10] ci: take the framework from Packagist now that v13.35.3 is out --- .github/workflows/tests.yml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 66e5254..c5f9246 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,9 +27,6 @@ jobs: extensions: dom, curl, libxml, mbstring, zip, pdo_sqlite coverage: none - - name: Use the unreleased framework (release branch until v13.35.3 is tagged) - run: composer require --no-update --no-interaction "pollora/framework:dev-release/v13.35.3 as 13.35.3" - - name: Install dependencies run: composer update --prefer-stable --prefer-dist --no-interaction @@ -50,9 +47,6 @@ jobs: extensions: dom, curl, libxml, mbstring, zip coverage: none - - name: Use the unreleased framework (release branch until v13.35.3 is tagged) - run: composer require --no-update --no-interaction "pollora/framework:dev-release/v13.35.3 as 13.35.3" - - name: Install dependencies run: composer update --prefer-stable --prefer-dist --no-interaction @@ -113,12 +107,10 @@ jobs: -e 's/^# DB_PASSWORD=.*/DB_PASSWORD=db/' \ .env - # Until v13.35.3 is tagged, the framework comes from its release branch. - name: Install the skeleton with this package checkout run: | - ddev composer config repositories.debugbar '{"type": "path", "url": "packages/debugbar", "options": {"symlink": false, "versions": {"pollora/debugbar": "0.1.0"}}}' - ddev composer require "pollora/framework:dev-release/v13.35.3 as 13.35.3" --with-all-dependencies --no-interaction --no-progress --no-scripts - ddev composer require --dev pollora/debugbar:0.1.0 --with-all-dependencies --no-interaction --no-progress + ddev composer config repositories.debugbar '{"type": "path", "url": "packages/debugbar", "options": {"symlink": false, "versions": {"pollora/debugbar": "1.0.0"}}}' + ddev composer require --dev pollora/debugbar:1.0.0 --with-all-dependencies --no-interaction --no-progress - name: Generate application key run: ddev exec php artisan key:generate --no-interaction