From 8b9f4bf142c0a49a035489d956fcd1c11d9745da Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 15:17:43 +0200 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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']), + ); +});