diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c4c4edc --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,184 @@ +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 + + 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/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..e07d535 --- /dev/null +++ b/src/Collector.php @@ -0,0 +1,198 @@ +}> + */ + 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; + } + + /** + * 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 'box'; + } + + /** + * 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..960d1a0 --- /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 = 'box', + ) {} + + 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..604e957 --- /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..310b4b7 --- /dev/null +++ b/src/Collectors/WpHooksCollector.php @@ -0,0 +1,173 @@ + $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 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 => self::describeClosure($callback), + 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; + } + + /** + * `$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. + * + * 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 @%s', self::describe($callback), self::priority((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..034c093 --- /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..fd0fead --- /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, string $icon = 'box'): static + { + $this->collectors[] = new CallbackCollector($this->checked($name), $title, $origin, $values, icon: $icon); + + 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 = [], string $icon = 'table'): static + { + $this->collectors[] = new CallbackCollector($this->checked($name), $title, $origin, $rows, Widget::Table, $columns, $icon); + + 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..ea7bc07 --- /dev/null +++ b/tests/Unit/Collectors/WpHooksCollectorTest.php @@ -0,0 +1,47 @@ + [['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))->toMatch('/^closure in P\\\\Tests\\\\Unit\\\\Collectors\\\\WpHooksCollectorTest\\S* \\(WpHooksCollectorTest\\.php:\\d+\\)$/') + ->and(WpHooksCollector::describe((new ArrayObject)->count(...)))->toBe('ArrayObject::count'); +}); 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(); +}); 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']), + ); +});