diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c4c4edc..9ed9ef3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,6 @@ on: push: branches: [ main, develop ] pull_request: - branches: [ main, develop ] workflow_dispatch: jobs: diff --git a/README.md b/README.md index 0286e3f..037efbf 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,22 @@ It brings `fruitcake/laravel-debugbar` with it. Nothing runs unless Laravel Debu | Tab | Origin | Shows | | --- | --- | --- | -| Pollora | Pollora | What answered (`Route::wp()`, the template hierarchy and its view, a Laravel route, or WordPress alone), versions, discovery, modules, theme, async actions | +| Pollora | Pollora | What answered (`Route::wp()`, the template hierarchy and its view, a Laravel route, or WordPress alone), versions, discovery, modules, theme, async actions registered and queued, WordPress constants and drop-ins | +| Doctor | Pollora | A **Run doctor** button: `pollora:doctor`'s web checks on demand, errors first | | WP Request | WordPress | Rewrite rule, query vars, queried object, main query, true conditionals, template and hierarchy candidates | -| WP Queries | WordPress | `$wpdb` queries with time, caller, duplicates, slow ones and the main query (turns `SAVEQUERIES` on) | +| WP Queries | WordPress | `$wpdb` queries with time, full backtrace, rows, errors, duplicates, slow ones and the main query, grouped by component (core, plugin, theme…) | | WP Hooks | WordPress | Hooks that ran, their callbacks, and those Pollora registered | +| WP HTTP | WordPress | `wp_remote_*` calls: result, time, transport, who made them | +| WP Cache | WordPress | Object cache hits and misses, transients set, OPcache | +| WP Capabilities | WordPress | `current_user_can()` checks, each distinct check once with its count | +| WP Blocks | WordPress | Blocks rendered by type with their time, Pollora's Blade blocks, block bindings | +| WP Assets | WordPress | Scripts, styles and script modules, header or footer, missing dependencies, Vite builds | +| WP Languages | WordPress | Locale and the translation files looked for | | Timeline | WordPress | `muplugins_loaded` to `shutdown`, beside Debugbar's own measures | -REST and admin-ajax requests end with `exit`, which Laravel Debugbar never sees: this package stores them and sends the `phpdebugbar-id` header, so they appear in the bar's request list of the page that made the call. +REST and admin-ajax requests end with `exit`, which Laravel Debugbar never sees: this package stores them and sends the `phpdebugbar-id` header, so they appear in the bar's request list of the page that made the call. A `wp_redirect()` keeps its request for the page it leads to. + +WordPress queries are traced through core's `log_query_custom_data` and `query` filters, so this works with any `db.php` drop-in, Pollora's included. Configuration: `php artisan vendor:publish --tag=debugbar-pollora-config`. diff --git a/config/debugbar-pollora.php b/config/debugbar-pollora.php index 51b2371..3c88cba 100644 --- a/config/debugbar-pollora.php +++ b/config/debugbar-pollora.php @@ -27,6 +27,13 @@ | wp_queries $wpdb queries (turns SAVEQUERIES on) | wp_hooks Hooks that ran and the callbacks Pollora registered | wp_timeline WordPress phases on Debugbar's timeline + | wp_http HTTP calls made through wp_remote_* + | wp_cache Object cache, transients set, OPcache + | wp_capabilities current_user_can() checks, aggregated + | wp_blocks Blocks rendered and block bindings resolved + | wp_assets Scripts, styles, script modules and Vite containers + | wp_languages Locale and translation files + | doctor A button that runs pollora:doctor's web checks on demand | bridges pollora/debugbar/* and Query Monitor's qm/* actions | */ @@ -37,6 +44,13 @@ 'wp_queries' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_QUERIES', true), 'wp_hooks' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_HOOKS', true), 'wp_timeline' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_TIMELINE', true), + 'wp_http' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_HTTP', true), + 'wp_cache' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_CACHE', true), + 'wp_capabilities' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_CAPABILITIES', true), + 'wp_blocks' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_BLOCKS', true), + 'wp_assets' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_ASSETS', true), + 'wp_languages' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_LANGUAGES', true), + 'doctor' => env('DEBUGBAR_POLLORA_COLLECTORS_DOCTOR', true), 'bridges' => env('DEBUGBAR_POLLORA_COLLECTORS_BRIDGES', true), ], @@ -49,6 +63,13 @@ 'soft_limit' => (int) env('DEBUGBAR_POLLORA_WP_QUERIES_SOFT_LIMIT', 100), // Past this many queries, the rest are left out 'hard_limit' => (int) env('DEBUGBAR_POLLORA_WP_QUERIES_HARD_LIMIT', 500), + // Full backtrace, error, rows and component for each query + 'trace' => env('DEBUGBAR_POLLORA_WP_QUERIES_TRACE', true), + ], + + 'wp_capabilities' => [ + // Who asked: a backtrace per distinct check + 'backtrace' => env('DEBUGBAR_POLLORA_WP_CAPABILITIES_BACKTRACE', false), ], 'wp_hooks' => [ diff --git a/resources/doctor-widget.js b/resources/doctor-widget.js new file mode 100644 index 0000000..33c08cd --- /dev/null +++ b/resources/doctor-widget.js @@ -0,0 +1,95 @@ +/* + * The Doctor tab: a button that runs pollora:doctor's web checks on demand + * and lists what they found, errors first. + */ +(function () { + if (! window.PhpDebugBar || PhpDebugBar.Widgets.PolloraDoctorWidget) { + return; + } + + const csscls = PhpDebugBar.utils.makecsscls('phpdebugbar-widgets-'); + + class PolloraDoctorWidget extends PhpDebugBar.Widget { + get className() { + return csscls('pollora-doctor'); + } + + render() { + const toolbar = document.createElement('div'); + toolbar.style.padding = '8px'; + + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = 'Run doctor'; + + const status = document.createElement('span'); + status.style.marginLeft = '8px'; + status.textContent = 'Runs pollora:doctor\'s web checks for this site.'; + + const table = document.createElement('table'); + table.classList.add(csscls('tablevar')); + table.style.width = '100%'; + + toolbar.append(button, status); + this.el.append(toolbar, table); + + button.addEventListener('click', async () => { + const url = this.get('data')?.url; + + if (! url) { + return; + } + + button.disabled = true; + status.textContent = 'Running…'; + table.innerHTML = ''; + + try { + const response = await fetch(url, { headers: { Accept: 'application/json' }, credentials: 'same-origin' }); + const text = await response.text(); + const isJson = (response.headers.get('content-type') || '').includes('json'); + + // A proxy or a restarting server answers with plain text or + // HTML: say what came back rather than a JSON syntax error + if (! isJson) { + throw new Error('HTTP ' + response.status + ': ' + text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120)); + } + + const body = JSON.parse(text); + + if (! response.ok) { + throw new Error(body.message || 'HTTP ' + response.status); + } + + const counts = {}; + + for (const check of body.checks) { + counts[check.status] = (counts[check.status] || 0) + 1; + + const row = document.createElement('tr'); + row.classList.add(csscls('item')); + + const details = (check.details || []).join(' · '); + + for (const value of [check.status, check.label, [check.summary, details, check.fix ? 'Fix: ' + check.fix : ''].filter(Boolean).join(' — ')]) { + const cell = document.createElement('td'); + cell.classList.add(csscls('value')); + cell.textContent = value; + row.append(cell); + } + + table.append(row); + } + + status.textContent = Object.entries(counts).map(([name, count]) => count + ' ' + name).join(', ') || 'No web checks.'; + } catch (error) { + status.textContent = 'The doctor could not run: ' + error.message; + } finally { + button.disabled = false; + } + }); + } + } + + PhpDebugBar.Widgets.PolloraDoctorWidget = PolloraDoctorWidget; +})(); diff --git a/src/Collector.php b/src/Collector.php index e07d535..a59bf7a 100644 --- a/src/Collector.php +++ b/src/Collector.php @@ -134,7 +134,7 @@ public function getWidgets(): array } /** - * @return array + * @return array */ public function getAssets(): array { @@ -150,6 +150,21 @@ public function getAssets(): array public function reset(): void {} + /** + * A path relative to the project, so the bar names a file someone can open. + */ + protected function relativePath(string $path): string + { + try { + $base = function_exists('base_path') ? rtrim(base_path(), '/').'/' : ''; + } catch (\Throwable) { + // No application to ask: show the path as it is + return $path; + } + + return $base !== '' && str_starts_with($path, $base) ? substr($path, strlen($base)) : $path; + } + /** * What the tab shows, in the shape its widget takes. * diff --git a/src/CollectorRegistrar.php b/src/CollectorRegistrar.php index e5fb241..ca3fe36 100644 --- a/src/CollectorRegistrar.php +++ b/src/CollectorRegistrar.php @@ -8,13 +8,28 @@ use DebugBar\DataCollector\TimeDataCollector; use Fruitcake\LaravelDebugbar\LaravelDebugbar; use Illuminate\Contracts\Container\Container; +use Pollora\Debugbar\Collectors\DoctorCollector; use Pollora\Debugbar\Collectors\PolloraCollector; +use Pollora\Debugbar\Collectors\WpAssetsCollector; +use Pollora\Debugbar\Collectors\WpBlocksCollector; +use Pollora\Debugbar\Collectors\WpCacheCollector; +use Pollora\Debugbar\Collectors\WpCapabilitiesCollector; use Pollora\Debugbar\Collectors\WpHooksCollector; +use Pollora\Debugbar\Collectors\WpHttpCollector; +use Pollora\Debugbar\Collectors\WpLanguagesCollector; use Pollora\Debugbar\Collectors\WpQueriesCollector; use Pollora\Debugbar\Collectors\WpRequestCollector; use Pollora\Debugbar\Collectors\WpTimelineCollector; use Pollora\Debugbar\Contracts\SectionProvider; +use Pollora\Debugbar\Recording\AsyncRecorder; +use Pollora\Debugbar\Recording\BlockRecorder; +use Pollora\Debugbar\Recording\CacheRecorder; +use Pollora\Debugbar\Recording\CapabilityRecorder; +use Pollora\Debugbar\Recording\HttpRecorder; +use Pollora\Debugbar\Recording\LanguageRecorder; +use Pollora\Debugbar\Recording\QueryTracer; use Pollora\Debugbar\Recording\RequestRecorder; +use Pollora\Debugbar\Support\Components; use Pollora\Hook\Domain\Contract\Action; use Pollora\Hook\Domain\Contract\Filter; @@ -98,7 +113,7 @@ private function builtIns(LaravelDebugbar $debugbar): array $collectors = []; if ($on('pollora')) { - $collectors[] = new PolloraCollector($this->container); + $collectors[] = new PolloraCollector($this->container, $this->container->make(AsyncRecorder::class)); } if ($on('wp_request')) { @@ -112,6 +127,8 @@ private function builtIns(LaravelDebugbar $debugbar): array is_numeric($threshold) ? (float) $threshold : null, (int) $config->get('debugbar-pollora.options.wp_queries.soft_limit', 100), (int) $config->get('debugbar-pollora.options.wp_queries.hard_limit', 500), + $this->container->make(Components::class), + $config->get('debugbar-pollora.options.wp_queries.trace', true) ? $this->container->make(QueryTracer::class) : null, ); } @@ -119,6 +136,36 @@ private function builtIns(LaravelDebugbar $debugbar): array $collectors[] = new WpHooksCollector($this->recorder, $this->polloraHookServices()); } + $components = $this->container->make(Components::class); + + if ($on('wp_http')) { + $collectors[] = new WpHttpCollector($this->container->make(HttpRecorder::class), $components); + } + + if ($on('wp_cache')) { + $collectors[] = new WpCacheCollector($this->container->make(CacheRecorder::class), $components); + } + + if ($on('wp_capabilities')) { + $collectors[] = new WpCapabilitiesCollector($this->container->make(CapabilityRecorder::class), $components); + } + + if ($on('wp_blocks')) { + $collectors[] = new WpBlocksCollector($this->container->make(BlockRecorder::class)); + } + + if ($on('wp_assets')) { + $collectors[] = new WpAssetsCollector($this->container); + } + + if ($on('wp_languages')) { + $collectors[] = new WpLanguagesCollector($this->container->make(LanguageRecorder::class)); + } + + if ($on('doctor')) { + $collectors[] = new DoctorCollector('/'.trim((string) $config->get('debugbar.route_prefix', '_debugbar'), '/').'/pollora/doctor'); + } + if ($on('wp_timeline') && $debugbar->hasCollector('time')) { $time = $debugbar->getCollector('time'); diff --git a/src/Collectors/DoctorCollector.php b/src/Collectors/DoctorCollector.php new file mode 100644 index 0000000..458afcc --- /dev/null +++ b/src/Collectors/DoctorCollector.php @@ -0,0 +1,82 @@ + $this->data(), 'count' => null]; + } + + /** + * @return array> + */ + public function getWidgets(): array + { + $widgets = parent::getWidgets(); + $widgets['pollora_doctor']['widget'] = 'PhpDebugBar.Widgets.PolloraDoctorWidget'; + + return $widgets; + } + + /** + * @return array{inline_js: array} + */ + public function getAssets(): array + { + return ['inline_js' => ['pollora-doctor-widget' => (string) file_get_contents(__DIR__.'/../../resources/doctor-widget.js')]]; + } + + /** + * @return array{url: string} + */ + protected function data(): array + { + return ['url' => $this->url]; + } +} diff --git a/src/Collectors/PolloraCollector.php b/src/Collectors/PolloraCollector.php index 604e957..4127ff0 100644 --- a/src/Collectors/PolloraCollector.php +++ b/src/Collectors/PolloraCollector.php @@ -10,6 +10,7 @@ use Illuminate\Routing\Route; use Pollora\Debugbar\Collector; use Pollora\Debugbar\Origin; +use Pollora\Debugbar\Recording\AsyncRecorder; use Pollora\Discovery\Application\Services\DiscoveryManager; use Pollora\Hook\Infrastructure\Services\AsyncInspector; use Pollora\Modules\Application\Services\ModuleStates; @@ -29,6 +30,7 @@ final class PolloraCollector extends Collector { public function __construct( private readonly Container $container, + private readonly ?AsyncRecorder $async = null, ) {} public function getName(): string @@ -64,6 +66,8 @@ protected function data(): array 'Modules' => $this->safely($this->modules(...)), 'Theme' => $this->safely($this->theme(...)), 'Async actions' => $this->safely($this->asyncActions(...)), + 'Async dispatched' => $this->safely($this->asyncDispatched(...)), + 'WordPress' => $this->safely($this->wordpress(...)), ]; } @@ -110,7 +114,7 @@ private function template(): ?array } return [ - 'file' => $this->relative($resolution->template), + 'file' => $this->relativePath($resolution->template), 'view' => $resolution->view, 'condition' => $resolution->condition, 'index fallback' => $resolution->usedIndexFallback, @@ -157,7 +161,7 @@ private function discovery(): ?array 'time' => sprintf('%.1f ms', array_sum(array_column($scans, 'milliseconds'))), 'locations' => array_map(fn (array $scan): string => sprintf( '%s · %s · %d structures · %.1f ms', - $this->relative($scan['path']), + $this->relativePath($scan['path']), $scan['source'], $scan['structures'], $scan['milliseconds'], @@ -225,6 +229,60 @@ private function asyncActions(): ?array ) ?: null; } + /** + * What this request queued, with `pollora/hook` 1.5+. + * + * @return list|null + */ + private function asyncDispatched(): ?array + { + $dispatched = $this->async?->dispatched() ?? []; + + return array_map( + static fn (array $item): string => sprintf('%s → %s · %s%s', $item['hook'], $item['handler'], $item['driver'] !== '' ? $item['driver'] : 'default driver', $item['delay'] > 0 ? " · in {$item['delay']} s" : ''), + $dispatched, + ) ?: null; + } + + /** + * The constants and drop-ins that change how WordPress behaves. + * + * @return array + */ + private function wordpress(): array + { + $constants = []; + + foreach (['WP_DEBUG', 'WP_DEBUG_LOG', 'WP_DEBUG_DISPLAY', 'SCRIPT_DEBUG', 'SAVEQUERIES', 'WP_CACHE', 'DISABLE_WP_CRON', 'CONCATENATE_SCRIPTS'] as $constant) { + $constants[$constant] = defined($constant) ? constant($constant) : 'undefined'; + } + + $dropIns = []; + + if (defined('WP_CONTENT_DIR')) { + foreach (['db.php', 'object-cache.php', 'advanced-cache.php', 'maintenance.php', 'sunrise.php'] as $dropIn) { + if (is_file(WP_CONTENT_DIR.'/'.$dropIn)) { + $dropIns[] = $dropIn; + } + } + } + + global $wpdb; + + return [ + 'environment' => function_exists('wp_get_environment_type') ? wp_get_environment_type() : null, + 'constants' => $constants, + 'drop-ins' => $dropIns === [] ? 'none' : implode(', ', $dropIns), + 'database server' => is_object($wpdb) && method_exists($wpdb, 'db_server_info') ? (string) $wpdb->db_server_info() : null, + 'memory limit' => sprintf( + 'PHP %s, WordPress %s', + (string) ini_get('memory_limit'), + defined('WP_MEMORY_LIMIT') ? (string) WP_MEMORY_LIMIT : '—', + ), + 'web server' => is_string($_SERVER['SERVER_SOFTWARE'] ?? null) ? $_SERVER['SERVER_SOFTWARE'] : null, + ]; + } + private function resolution(): ?TemplateResolution { return $this->container->bound(AnsweringTemplate::class) @@ -243,11 +301,4 @@ private function safely(\Closure $read): mixed return 'unavailable: '.$throwable->getMessage(); } } - - private function relative(string $path): string - { - $base = function_exists('base_path') ? rtrim(base_path(), '/').'/' : ''; - - return $base !== '' && str_starts_with($path, $base) ? substr($path, strlen($base)) : $path; - } } diff --git a/src/Collectors/WpAssetsCollector.php b/src/Collectors/WpAssetsCollector.php new file mode 100644 index 0000000..3d24ad5 --- /dev/null +++ b/src/Collectors/WpAssetsCollector.php @@ -0,0 +1,186 @@ + + */ + public function columns(): array + { + return ['printed' => 'Printed', 'source' => 'Source', 'dependencies' => 'Dependencies', 'note' => 'Note']; + } + + /** + * @return array + */ + protected function data(): array + { + global $wp_scripts, $wp_styles; + + return [ + ...$this->vite(), + ...$this->dependencies('script', $wp_scripts), + ...$this->dependencies('style', $wp_styles), + ...$this->modules(), + ]; + } + + /** + * @return array + */ + private function dependencies(string $kind, mixed $dependencies): array + { + if (! $dependencies instanceof \WP_Dependencies) { + return []; + } + + $rows = []; + $handles = array_unique([...$dependencies->queue, ...$dependencies->done]); + + foreach ($handles as $handle) { + $asset = $dependencies->registered[$handle] ?? null; + $deps = $asset instanceof \_WP_Dependency ? $asset->deps : []; + $missing = array_filter($deps, static fn (string $dependency): bool => ! isset($dependencies->registered[$dependency])); + $printed = in_array($handle, $dependencies->done, true); + + $rows["{$kind}: {$handle}"] = [ + 'printed' => match (true) { + ! $printed => 'no', + $kind === 'script' && (int) $dependencies->get_data($handle, 'group') === 1 => 'footer', + default => 'header', + }, + 'source' => $asset instanceof \_WP_Dependency && is_string($asset->src) ? $this->shortUrl($asset->src) : '', + 'dependencies' => implode(', ', $deps), + 'note' => $missing !== [] ? 'missing: '.implode(', ', $missing) : '', + ]; + } + + return $rows; + } + + /** + * @return array + */ + private function modules(): array + { + if (! function_exists('wp_script_modules')) { + return []; + } + + $modules = wp_script_modules(); + + if (! method_exists($modules, 'get_queue')) { + return []; + } + + $rows = []; + + foreach ($modules->get_queue() as $id) { + $module = method_exists($modules, 'get_registered') ? $modules->get_registered($id) : null; + + $rows["module: {$id}"] = $module === null + ? ['printed' => '', 'source' => '', 'dependencies' => '', 'note' => 'not registered'] + : [ + 'printed' => $module['in_footer'] ? 'footer' : 'header', + 'source' => $this->shortUrl($module['src']), + 'dependencies' => implode(', ', array_column($module['dependencies'], 'id')), + 'note' => '', + ]; + } + + return $rows; + } + + /** + * Pollora's Vite containers: dev server or build. + * + * @return array + */ + private function vite(): array + { + if (! $this->container->bound(AssetManager::class)) { + return []; + } + + $manager = $this->container->make(AssetManager::class); + + if (! method_exists($manager, 'containers')) { + return []; + } + + $rows = []; + + foreach ($manager->containers() as $name => $container) { + $hot = is_file($container->getHotFile()); + $manifest = function_exists('public_path') ? public_path(trim($container->getBuildDirectory(), '/').'/'.$container->getManifestPath()) : ''; + + $rows["vite: {$name}"] = [ + 'printed' => '', + 'source' => $hot ? trim((string) file_get_contents($container->getHotFile())) : $this->shortUrl($container->getBuildDirectory()), + 'dependencies' => '', + 'note' => $hot ? 'dev server (hot file)' : ($manifest !== '' && is_file($manifest) ? 'build (manifest)' : 'no manifest: nothing built here'), + ]; + } + + return $rows; + } + + private function shortUrl(string $url): string + { + $home = function_exists('home_url') ? rtrim(home_url(), '/') : ''; + + return $home !== '' && str_starts_with($url, $home) ? substr($url, strlen($home)) : $url; + } +} diff --git a/src/Collectors/WpBlocksCollector.php b/src/Collectors/WpBlocksCollector.php new file mode 100644 index 0000000..d18ebba --- /dev/null +++ b/src/Collectors/WpBlocksCollector.php @@ -0,0 +1,138 @@ + + */ + public function columns(): array + { + return ['count' => 'Count', 'time' => 'Time', 'kind' => 'Kind', 'detail' => 'Detail']; + } + + /** + * @return array + */ + protected function data(): array + { + $formatter = $this->getDataFormatter(); + $rows = []; + + foreach ($this->recorder->blocks() as $name => $block) { + $rows[$name] = [ + 'count' => $block['count'], + 'time' => $formatter->formatDuration($block['milliseconds'] / 1000), + 'kind' => $this->kind($name), + 'detail' => $block['maxDepth'] > 0 ? 'nested up to '.$block['maxDepth'].' deep' : '', + ]; + } + + $bindings = []; + + foreach ($this->recorder->bindings() as $binding) { + $key = 'binding '.$binding->source.($binding->field !== '' ? '.'.$binding->field : '').' → '.$binding->attribute; + $row = $bindings[$key] ?? ['count' => 0, 'milliseconds' => 0.0, 'cached' => 0, 'empty' => 0, 'posts' => []]; + $row['count']++; + $row['milliseconds'] += $binding->milliseconds; + $row['cached'] += $binding->cached ? 1 : 0; + $row['empty'] += $binding->hasValue ? 0 : 1; + + if ($binding->postId !== null) { + $row['posts'][$binding->postId] = true; + } + + $bindings[$key] = $row; + } + + foreach ($bindings as $key => $row) { + $rows[$key] = [ + 'count' => $row['count'], + 'time' => $formatter->formatDuration($row['milliseconds'] / 1000), + 'kind' => 'binding', + 'detail' => implode(', ', array_filter([ + $row['posts'] !== [] ? 'post '.implode(', ', array_keys($row['posts'])) : null, + $row['cached'] > 0 ? $row['cached'].' from cache' : null, + $row['empty'] > 0 ? $row['empty'].' without value' : null, + ])), + ]; + } + + return $rows; + } + + /** + * Core, a Pollora Blade block, or another plugin's. + */ + private function kind(string $name): string + { + if (str_starts_with($name, 'core/')) { + return 'core'; + } + + if (! class_exists(\WP_Block_Type_Registry::class)) { + return 'block'; + } + + $type = \WP_Block_Type_Registry::get_instance()->get_registered($name); + $render = $type instanceof \WP_Block_Type ? $type->render_callback : null; + + if ($render instanceof \Closure) { + $scope = (new \ReflectionFunction($render))->getClosureScopeClass()?->getName() ?? ''; + + if (str_starts_with($scope, 'Pollora\\')) { + return 'Blade (Pollora)'; + } + } + + return $render !== null ? 'dynamic' : 'static'; + } +} diff --git a/src/Collectors/WpCacheCollector.php b/src/Collectors/WpCacheCollector.php new file mode 100644 index 0000000..b88a819 --- /dev/null +++ b/src/Collectors/WpCacheCollector.php @@ -0,0 +1,102 @@ + + */ + protected function data(): array + { + global $wp_object_cache; + + $hits = is_object($wp_object_cache) && isset($wp_object_cache->cache_hits) ? (int) $wp_object_cache->cache_hits : null; + $misses = is_object($wp_object_cache) && isset($wp_object_cache->cache_misses) ? (int) $wp_object_cache->cache_misses : null; + + $transients = []; + + foreach ($this->recorder->transients() as $transient) { + $transients[] = sprintf( + '%s%s · %s · %d bytes · %s', + $transient['network'] ? 'site: ' : '', + $transient['name'], + $transient['expiration'] > 0 ? "expires in {$transient['expiration']} s" : 'no expiration', + $transient['size'], + $this->components->ofTrace($transient['frames']), + ); + } + + return [ + 'Object cache' => match (true) { + ! is_object($wp_object_cache) => null, + function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache() => 'persistent ('.$wp_object_cache::class.')', + default => 'in memory for this request only ('.$wp_object_cache::class.')', + }, + 'Hits' => $hits, + 'Misses' => $misses, + 'Hit ratio' => $hits !== null && $misses !== null && $hits + $misses > 0 ? sprintf('%.1f %%', $hits / ($hits + $misses) * 100) : null, + 'Transients set' => $transients === [] ? 'none' : $transients, + 'OPcache' => $this->opcache(), + ]; + } + + private function opcache(): string + { + if (! function_exists('opcache_get_status')) { + return 'not installed'; + } + + $status = @opcache_get_status(false); + + if (! is_array($status) || ! ($status['opcache_enabled'] ?? false)) { + return 'off'; + } + + $statistics = $status['opcache_statistics'] ?? []; + + return sprintf('on, %.1f %% hits', (float) ($statistics['opcache_hit_rate'] ?? 0)); + } +} diff --git a/src/Collectors/WpCapabilitiesCollector.php b/src/Collectors/WpCapabilitiesCollector.php new file mode 100644 index 0000000..698b1b1 --- /dev/null +++ b/src/Collectors/WpCapabilitiesCollector.php @@ -0,0 +1,88 @@ + + */ + public function columns(): array + { + return ['result' => 'Result', 'user' => 'User', 'count' => 'Checks', 'component' => 'From']; + } + + /** + * @return array + */ + protected function data(): array + { + $rows = []; + + foreach ($this->recorder->checks() as $check) { + $label = $check['capability'].($check['arguments'] !== '' ? "({$check['arguments']})" : ''); + $key = $label; + + // The same capability can be granted to one user and refused to another + while (isset($rows[$key])) { + $key .= ' '; + } + + $rows[$key] = [ + 'result' => $check['granted'] ? 'granted' : 'refused', + 'user' => $check['user'] > 0 ? '#'.$check['user'] : 'visitor', + 'count' => $check['count'], + 'component' => $check['frames'] !== [] ? $this->components->ofTrace($check['frames']) : '—', + ]; + } + + return $rows; + } +} diff --git a/src/Collectors/WpHttpCollector.php b/src/Collectors/WpHttpCollector.php new file mode 100644 index 0000000..7cdaf4e --- /dev/null +++ b/src/Collectors/WpHttpCollector.php @@ -0,0 +1,86 @@ + + */ + public function columns(): array + { + return ['result' => 'Result', 'time' => 'Time', 'transport' => 'Transport', 'component' => 'From']; + } + + /** + * @return array + */ + protected function data(): array + { + $rows = []; + + foreach ($this->recorder->calls() as $index => $call) { + $result = match (true) { + $call['error'] !== null => 'error: '.$call['error'], + $call['end'] === null => 'no response recorded', + $call['shortCircuited'] => ($call['status'] ?? 'answered').' (answered by pre_http_request)', + default => (string) ($call['status'] ?? '—'), + }; + + $rows[sprintf('%d. %s %s', $index + 1, $call['method'], $call['url'])] = [ + 'result' => $result, + 'time' => $call['end'] !== null ? $this->getDataFormatter()->formatDuration($call['end'] - $call['start']) : '—', + 'transport' => $call['transport'] ?? '—', + 'component' => $this->components->ofTrace($call['frames']), + ]; + } + + return $rows; + } +} diff --git a/src/Collectors/WpLanguagesCollector.php b/src/Collectors/WpLanguagesCollector.php new file mode 100644 index 0000000..5c77e8b --- /dev/null +++ b/src/Collectors/WpLanguagesCollector.php @@ -0,0 +1,80 @@ + + */ + public function columns(): array + { + return ['file' => 'File', 'found' => 'Found', 'kind' => 'Kind']; + } + + /** + * @return array + */ + protected function data(): array + { + $rows = []; + + if (function_exists('determine_locale')) { + $rows['locale'] = ['file' => determine_locale().(function_exists('get_user_locale') ? ' (user: '.get_user_locale().')' : ''), 'found' => '', 'kind' => '']; + } + + foreach ($this->recorder->files() as $index => $file) { + $rows[sprintf('%d. %s', $index + 1, $file['domain'])] = [ + 'file' => $this->relativePath($file['file']), + 'found' => $file['found'] ? 'yes' : 'no', + 'kind' => $file['kind'], + ]; + } + + return $rows; + } +} diff --git a/src/Collectors/WpQueriesCollector.php b/src/Collectors/WpQueriesCollector.php index 60e5d26..ce4a006 100644 --- a/src/Collectors/WpQueriesCollector.php +++ b/src/Collectors/WpQueriesCollector.php @@ -6,6 +6,8 @@ use Pollora\Debugbar\Collector; use Pollora\Debugbar\Origin; +use Pollora\Debugbar\Recording\QueryTracer; +use Pollora\Debugbar\Support\Components; use Pollora\Debugbar\Widget; /** @@ -14,7 +16,9 @@ * Laravel Debugbar only sees Laravel's PDO connection; WordPress talks to the * same database through its own mysqli one. With `SAVEQUERIES` on, `$wpdb` * keeps each query with its time and a caller string, read here once, at the - * end of the request. + * end of the request. With the query tracer on, each query also carries its + * full backtrace, error and row count, and is attributed to a component — + * shown as the SQL widget's connection, so its filter links group by it. */ final class WpQueriesCollector extends Collector { @@ -27,6 +31,8 @@ public function __construct( private readonly ?float $slowThreshold = null, private readonly int $softLimit = 100, private readonly int $hardLimit = 500, + private readonly ?Components $components = null, + private readonly ?QueryTracer $tracer = null, ) {} public function getName(): string @@ -71,9 +77,13 @@ protected function data(): array ? trim($wp_the_query->request) : null; + $this->tracer?->completePrevious(); + $queries = is_object($wpdb) && isset($wpdb->queries) && is_array($wpdb->queries) ? $wpdb->queries : $queries; + $formatter = $this->getDataFormatter(); $statements = []; $total = 0.0; + $failed = 0; foreach (array_values($queries) as $index => $query) { if ($index >= $this->hardLimit) { @@ -82,9 +92,22 @@ protected function data(): array $sql = trim((string) ($query[0] ?? '')); $duration = (float) ($query[1] ?? 0); - $frames = $index < $this->softLimit ? $this->frames((string) ($query[2] ?? '')) : []; + $traced = is_array($query[4] ?? null) && is_array($query[4][QueryTracer::KEY] ?? null) ? $query[4][QueryTracer::KEY] : null; $total += $duration; + if ($traced !== null && ($traced['frames'] ?? []) !== []) { + $frames = array_map(fn (array $frame): string => sprintf('%s — %s:%d', $frame['call'], $this->relativePath($frame['file']), $frame['line']), $traced['frames']); + $component = is_string($traced['component'] ?? null) ? $traced['component'] : $this->components?->ofTrace($traced['frames']); + $source = $traced['frames'][0]['call']; + } else { + $frames = $index < $this->softLimit ? $this->frames((string) ($query[2] ?? '')) : []; + $component = null; + $source = $frames[0] ?? ''; + } + + $error = is_string($traced['error'] ?? null) ? $traced['error'] : null; + $failed += $error !== null ? 1 : 0; + $statements[] = [ 'sql' => $sql, 'type' => 'query', @@ -92,15 +115,15 @@ protected function data(): array 'duration' => $duration, 'duration_str' => $formatter->formatDuration($duration), 'memory_str' => '', - 'row_count' => null, - 'is_success' => true, + 'row_count' => is_int($traced['rows'] ?? null) ? $traced['rows'] : null, + 'is_success' => $error === null, 'error_code' => null, - 'error_message' => null, + 'error_message' => $error, 'backtrace' => $frames, - 'filename' => ($sql === $mainQuery ? 'main query · ' : '').($frames[0] ?? ''), + 'filename' => ($sql === $mainQuery ? 'main query · ' : '').$source, 'xdebug_link' => null, 'slow' => $this->slowThreshold !== null && $duration * 1000 >= $this->slowThreshold, - 'connection' => 'wpdb', + 'connection' => $component !== null ? "wpdb · {$component}" : 'wpdb', ]; } @@ -116,7 +139,7 @@ protected function data(): array return [ 'nb_statements' => count($statements), 'nb_excluded_statements' => max(0, count($queries) - count($statements)), - 'nb_failed_statements' => 0, + 'nb_failed_statements' => $failed, 'accumulated_duration' => $total, 'accumulated_duration_str' => $formatter->formatDuration($total), 'memory_usage_str' => '', diff --git a/src/Collectors/WpRequestCollector.php b/src/Collectors/WpRequestCollector.php index 034c093..347bfe0 100644 --- a/src/Collectors/WpRequestCollector.php +++ b/src/Collectors/WpRequestCollector.php @@ -93,7 +93,7 @@ protected function data(): array $data['Conditionals'] = implode(', ', $this->trueConditionals()); } - $data['Template'] = $this->recorder->template() !== null ? $this->relative($this->recorder->template()) : null; + $data['Template'] = $this->recorder->template() !== null ? $this->relativePath($this->recorder->template()) : null; $data['Template hierarchy'] = $this->recorder->hierarchies() !== [] ? $this->recorder->hierarchies() : null; return $data; @@ -131,11 +131,4 @@ private function queriedObject(): ?string default => null, }; } - - private function relative(string $path): string - { - $base = function_exists('base_path') ? rtrim(base_path(), '/').'/' : ''; - - return $base !== '' && str_starts_with($path, $base) ? substr($path, strlen($base)) : $path; - } } diff --git a/src/DebugbarServiceProvider.php b/src/DebugbarServiceProvider.php index fef541a..2028be9 100644 --- a/src/DebugbarServiceProvider.php +++ b/src/DebugbarServiceProvider.php @@ -5,10 +5,21 @@ namespace Pollora\Debugbar; use Fruitcake\LaravelDebugbar\LaravelDebugbar; +use Fruitcake\LaravelDebugbar\Middleware\DebugbarEnabled; use Illuminate\Support\ServiceProvider; +use Pollora\BlockBinding\Domain\Events\BindingResolved; use Pollora\Debugbar\Bridges\MessageBridge; +use Pollora\Debugbar\Http\DoctorController; use Pollora\Debugbar\Http\WordPressExitResponder; +use Pollora\Debugbar\Recording\AsyncRecorder; +use Pollora\Debugbar\Recording\BlockRecorder; +use Pollora\Debugbar\Recording\CacheRecorder; +use Pollora\Debugbar\Recording\CapabilityRecorder; +use Pollora\Debugbar\Recording\HttpRecorder; +use Pollora\Debugbar\Recording\LanguageRecorder; +use Pollora\Debugbar\Recording\QueryTracer; use Pollora\Debugbar\Recording\RequestRecorder; +use Pollora\Debugbar\Support\Components; use Pollora\WordPress\Events\WordPressBooting; /** @@ -27,6 +38,17 @@ public function register(): void $this->app->singleton(RequestRecorder::class); $this->app->singleton(CollectorRegistrar::class); + $this->app->singleton(Components::class, fn (): Components => new Components); + $this->app->singleton(QueryTracer::class, fn ($app): QueryTracer => new QueryTracer( + (int) $app->make('config')->get('debugbar-pollora.options.wp_queries.soft_limit', 100), + $app->make(Components::class), + )); + $this->app->singleton(HttpRecorder::class); + $this->app->singleton(CacheRecorder::class); + $this->app->singleton(LanguageRecorder::class); + $this->app->singleton(CapabilityRecorder::class, fn ($app): CapabilityRecorder => new CapabilityRecorder((bool) $app->make('config')->get('debugbar-pollora.options.wp_capabilities.backtrace', false))); + $this->app->singleton(BlockRecorder::class); + $this->app->singleton(AsyncRecorder::class); if (! Activation::shouldRun($this->app)) { return; @@ -38,11 +60,33 @@ public function register(): void define('SAVEQUERIES', true); } - $this->app->make('events')->listen(WordPressBooting::class, function () use ($config): void { + $on = static fn (string $collector): bool => (bool) $config->get("debugbar-pollora.collectors.{$collector}", true); + + if ($on('wp_blocks') && class_exists(BindingResolved::class)) { + $this->app->make('events')->listen(BindingResolved::class, function (BindingResolved $binding): void { + $this->app->make(BlockRecorder::class)->recordBinding($binding); + }); + } + + $this->app->make('events')->listen(WordPressBooting::class, function () use ($config, $on): void { $this->app->make(RequestRecorder::class)->install( countAllHooks: (bool) $config->get('debugbar-pollora.options.wp_hooks.count_filters', false), ); + $recorders = [ + QueryTracer::class => $on('wp_queries') && $config->get('debugbar-pollora.options.wp_queries.trace', true), + HttpRecorder::class => $on('wp_http'), + CacheRecorder::class => $on('wp_cache'), + LanguageRecorder::class => $on('wp_languages'), + CapabilityRecorder::class => $on('wp_capabilities'), + BlockRecorder::class => $on('wp_blocks'), + AsyncRecorder::class => $on('pollora'), + ]; + + foreach (array_keys(array_filter($recorders)) as $recorder) { + $this->app->make($recorder)->install(); + } + if ($config->get('debugbar-pollora.collectors.bridges', true)) { (new MessageBridge($this->collectingDebugbar(...)))->install( queryMonitor: (bool) $config->get('debugbar-pollora.options.bridges.query_monitor', true), @@ -51,7 +95,13 @@ public function register(): void (new WordPressExitResponder( $this->collectingDebugbar(...), - fn (LaravelDebugbar $debugbar) => $this->app->make(CollectorRegistrar::class)->register($debugbar), + function (LaravelDebugbar $debugbar): void { + // WordPress can exit before the booted callbacks that index + // route names have run, and Debugbar's collectors link to + // its own named routes + $this->app->make('router')->getRoutes()->refreshNameLookups(); + $this->app->make(CollectorRegistrar::class)->register($debugbar); + }, ))->install(); }); } @@ -66,6 +116,13 @@ public function boot(): void return; } + if ($this->app->make('config')->get('debugbar-pollora.collectors.doctor', true)) { + $this->app->make('router') + ->get(trim((string) $this->app->make('config')->get('debugbar.route_prefix', '_debugbar'), '/').'/pollora/doctor', DoctorController::class) + ->middleware([...(array) $this->app->make('config')->get('debugbar.route_middleware', []), DebugbarEnabled::class]) + ->name('debugbar.pollora.doctor'); + } + $this->app->booted(function (): void { $debugbar = $this->collectingDebugbar(); diff --git a/src/Http/DoctorController.php b/src/Http/DoctorController.php new file mode 100644 index 0000000..21413b5 --- /dev/null +++ b/src/Http/DoctorController.php @@ -0,0 +1,49 @@ +isStorageOpen($request)) { + return new JsonResponse(['message' => 'The doctor answers local and private addresses only, as Debugbar\'s stored requests do (debugbar.storage.open).'], 403); + } + + if (! class_exists(Doctor::class)) { + return new JsonResponse(['message' => 'pollora:doctor is not available in this version of the framework.'], 501); + } + + $order = ['error' => 0, 'warning' => 1, 'ok' => 2, 'skipped' => 3]; + + $checks = array_map(static fn (array $run): array => [ + 'id' => $run['check']->id(), + 'label' => $run['check']->label(), + 'status' => $run['result']->status->value, + 'summary' => $run['result']->summary, + 'details' => $run['result']->details, + 'fix' => $run['result']->fix, + ], $container->make(Doctor::class)->run(RunContext::Http)); + + usort($checks, static fn (array $a, array $b): int => $order[$a['status']] <=> $order[$b['status']]); + + return new JsonResponse(['checks' => $checks]); + } +} diff --git a/src/Http/WordPressExitResponder.php b/src/Http/WordPressExitResponder.php index 03529ff..0b4b6e5 100644 --- a/src/Http/WordPressExitResponder.php +++ b/src/Http/WordPressExitResponder.php @@ -5,6 +5,8 @@ namespace Pollora\Debugbar\Http; use Fruitcake\LaravelDebugbar\LaravelDebugbar; +use Fruitcake\LaravelDebugbar\LaravelHttpDriver; +use Symfony\Component\HttpFoundation\Response; /** * Keeps the requests WordPress answers and then exits from — REST and @@ -16,6 +18,11 @@ * about them. Here the request id goes out in the `phpdebugbar-id` header * before any output, and the data is collected and stored at `shutdown`. The * bar's fetch and XHR capture then lists the call like any other. + * + * `wp_redirect()` is followed by `exit` too. The request is stacked as + * Debugbar stacks a Laravel redirect, so the next page shows both: its HTTP + * driver can only set its cookie on a Laravel response, so it is given one, + * and the cookie is sent by hand. */ final class WordPressExitResponder { @@ -37,6 +44,52 @@ public function install(): void add_filter('rest_post_dispatch', $this->tagRestResponse(...), 10, 1); add_action('admin_init', $this->tagAjaxResponse(...), 0, 0); add_action('shutdown', $this->collect(...), 0, 0); + add_filter('wp_redirect', function (mixed $location, mixed $status): mixed { + if (is_string($location) && $location !== '') { + $this->stackRedirect($location, (int) $status); + } + + return $location; + }, PHP_INT_MAX, 2); + } + + /** + * Keep this request for the page the redirect leads to. + */ + public function stackRedirect(string $location, int $status): void + { + $debugbar = ($this->debugbar)(); + + if (! $debugbar instanceof LaravelDebugbar) { + return; + } + + $debugbar->addMessage(sprintf('wp_redirect() %d to %s', $status, $location), 'redirect'); + + $driver = $debugbar->getHttpDriver(); + + if (headers_sent() || ! $driver instanceof LaravelHttpDriver) { + return; + } + + $response = new Response; + + // A debugging tool must never break the redirect it watches + try { + ($this->prepare)($debugbar); + $driver->setResponse($response); + $debugbar->stackData(); + } catch (\Throwable $throwable) { + report($throwable); + + return; + } finally { + $driver->setResponse(null); + } + + foreach ($response->headers->getCookies() as $cookie) { + header('Set-Cookie: '.$cookie, false); + } } /** @@ -87,10 +140,14 @@ public function collect(): void return; } - ($this->prepare)($debugbar); + try { + ($this->prepare)($debugbar); - // getData() collects, and stores, only when nothing has been collected yet. - $debugbar->getData(); + // getData() collects, and stores, only when nothing has been collected yet. + $debugbar->getData(); + } catch (\Throwable $throwable) { + report($throwable); + } } private function storingDebugbar(): ?LaravelDebugbar diff --git a/src/Recording/AsyncRecorder.php b/src/Recording/AsyncRecorder.php new file mode 100644 index 0000000..c28d9b2 --- /dev/null +++ b/src/Recording/AsyncRecorder.php @@ -0,0 +1,52 @@ + + */ + private array $dispatched = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_action')) { + return; + } + + $this->installed = true; + + add_action('pollora/async/dispatched', function (mixed $payload, mixed $delay = 0): void { + if (is_object($payload)) { + $this->dispatched[] = [ + 'hook' => (string) ($payload->hook ?? ''), + 'handler' => is_string($payload->handler ?? null) ? $payload->handler : get_debug_type($payload->handler ?? null), + 'driver' => (string) ($payload->driver ?? ''), + 'delay' => (int) $delay, + ]; + } + }, 10, 2); + } + + /** + * @return list + */ + public function dispatched(): array + { + return $this->dispatched; + } + + public function reset(): void + { + $this->dispatched = []; + } +} diff --git a/src/Recording/BlockRecorder.php b/src/Recording/BlockRecorder.php new file mode 100644 index 0000000..4b02ede --- /dev/null +++ b/src/Recording/BlockRecorder.php @@ -0,0 +1,101 @@ + + */ + private array $open = []; + + /** + * @var array + */ + private array $blocks = []; + + /** + * @var list + */ + private array $bindings = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_filter')) { + return; + } + + $this->installed = true; + + add_filter('pre_render_block', function (mixed $pre, mixed $parsedBlock): mixed { + if ($pre === null && is_array($parsedBlock)) { + $this->open[] = ['name' => (string) ($parsedBlock['blockName'] ?? 'freeform'), 'start' => microtime(true)]; + } + + return $pre; + }, PHP_INT_MAX, 2); + + add_filter('render_block', function (mixed $content): mixed { + $this->close(); + + return $content; + }, PHP_INT_MIN, 1); + } + + public function recordBinding(BindingResolved $binding): void + { + $this->bindings[] = $binding; + } + + /** + * @return array + */ + public function blocks(): array + { + return $this->blocks; + } + + /** + * @return list + */ + public function bindings(): array + { + return $this->bindings; + } + + public function reset(): void + { + $this->open = []; + $this->blocks = []; + $this->bindings = []; + } + + private function close(): void + { + $render = array_pop($this->open); + + if ($render === null) { + return; + } + + $block = $this->blocks[$render['name']] ?? ['count' => 0, 'milliseconds' => 0.0, 'maxDepth' => 0]; + $block['count']++; + $block['milliseconds'] += (microtime(true) - $render['start']) * 1000; + $block['maxDepth'] = max($block['maxDepth'], count($this->open)); + + $this->blocks[$render['name']] = $block; + } +} diff --git a/src/Recording/CacheRecorder.php b/src/Recording/CacheRecorder.php new file mode 100644 index 0000000..fb71fc2 --- /dev/null +++ b/src/Recording/CacheRecorder.php @@ -0,0 +1,62 @@ +}> + */ + private array $transients = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_action')) { + return; + } + + $this->installed = true; + + // set_transient / set_site_transient replaced setted_* in WordPress 6.8 + add_action('set_transient', function (mixed $name, mixed $value, mixed $expiration): void { + $this->record((string) $name, $value, (int) $expiration, false); + }, PHP_INT_MAX, 3); + add_action('set_site_transient', function (mixed $name, mixed $value, mixed $expiration): void { + $this->record((string) $name, $value, (int) $expiration, true); + }, PHP_INT_MAX, 3); + } + + /** + * @return list}> + */ + public function transients(): array + { + return $this->transients; + } + + public function reset(): void + { + $this->transients = []; + } + + private function record(string $name, mixed $value, int $expiration, bool $network): void + { + $this->transients[] = [ + 'name' => $name, + 'network' => $network, + 'expiration' => $expiration, + 'size' => strlen(serialize($value)), + 'frames' => array_values(array_filter( + array_map(static fn (array $frame): array => ['file' => $frame['file'] ?? ''], debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 15)), + static fn (array $frame): bool => $frame['file'] !== '', + )), + ]; + } +} diff --git a/src/Recording/CapabilityRecorder.php b/src/Recording/CapabilityRecorder.php new file mode 100644 index 0000000..2d4838c --- /dev/null +++ b/src/Recording/CapabilityRecorder.php @@ -0,0 +1,103 @@ +}> + */ + private array $checks = []; + + public function __construct( + private readonly bool $backtrace = false, + ) {} + + public function install(): void + { + if ($this->installed || ! function_exists('add_filter')) { + return; + } + + $this->installed = true; + + add_filter('user_has_cap', function (mixed $allcaps, mixed $caps, mixed $args): mixed { + if (is_array($allcaps) && is_array($caps) && is_array($args)) { + $this->record($allcaps, $caps, $args); + } + + return $allcaps; + }, PHP_INT_MAX, 3); + } + + /** + * @return list}> + */ + public function checks(): array + { + return array_values($this->checks); + } + + public function reset(): void + { + $this->checks = []; + } + + /** + * @param array $allcaps What the user has + * @param array $caps The primitive capabilities the check maps to + * @param array $args [requested capability, user id, ...extra arguments] + */ + private function record(array $allcaps, array $caps, array $args): void + { + $capability = (string) ($args[0] ?? ''); + $user = (int) ($args[1] ?? 0); + $arguments = implode(', ', array_map( + static fn (mixed $argument): string => is_scalar($argument) ? (string) $argument : get_debug_type($argument), + array_slice($args, 2), + )); + + // Granted when every primitive capability it maps to is held, as WP_User::has_cap() decides + $granted = $caps !== []; + + foreach ($caps as $cap) { + if (empty($allcaps[$cap])) { + $granted = false; + + break; + } + } + + $key = $user.'|'.$capability.'|'.$arguments.'|'.($granted ? 1 : 0); + + if (isset($this->checks[$key])) { + $this->checks[$key]['count']++; + + return; + } + + $this->checks[$key] = [ + 'user' => $user, + 'capability' => $capability, + 'arguments' => $arguments, + 'granted' => $granted, + 'count' => 1, + 'frames' => $this->backtrace ? array_values(array_filter( + array_map(static fn (array $frame): array => ['file' => $frame['file'] ?? ''], debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 15)), + static fn (array $frame): bool => $frame['file'] !== '', + )) : [], + ]; + } +} diff --git a/src/Recording/HttpRecorder.php b/src/Recording/HttpRecorder.php new file mode 100644 index 0000000..a7899f9 --- /dev/null +++ b/src/Recording/HttpRecorder.php @@ -0,0 +1,115 @@ +}> + */ + private array $calls = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_filter')) { + return; + } + + $this->installed = true; + + add_filter('http_request_args', fn (mixed $args, mixed $url): mixed => $this->starts($args, (string) $url), PHP_INT_MAX, 2); + add_filter('pre_http_request', fn (mixed $pre, mixed $args): mixed => $this->answeredInPlace($pre, $args), PHP_INT_MAX, 2); + add_action('http_api_debug', function (mixed $response, mixed $context, mixed $transport, mixed $args): void { + $this->ends($response, $args, is_string($transport) ? $transport : null); + }, PHP_INT_MAX, 4); + } + + /** + * @return list}> + */ + public function calls(): array + { + return array_values($this->calls); + } + + public function reset(): void + { + $this->calls = []; + } + + private function starts(mixed $args, string $url): mixed + { + if (! is_array($args)) { + return $args; + } + + $id = (string) count($this->calls); + $args[self::TAG] = $id; + + $this->calls[$id] = [ + 'method' => strtoupper(is_string($args['method'] ?? null) ? $args['method'] : 'GET'), + 'url' => $url, + 'start' => microtime(true), + 'end' => null, + 'status' => null, + 'error' => null, + 'transport' => null, + 'shortCircuited' => false, + 'frames' => array_values(array_filter( + array_map(static fn (array $frame): array => ['file' => $frame['file'] ?? ''], debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 20)), + static fn (array $frame): bool => $frame['file'] !== '', + )), + ]; + + return $args; + } + + private function answeredInPlace(mixed $pre, mixed $args): mixed + { + if ($pre !== false && is_array($args) && isset($this->calls[$args[self::TAG] ?? ''])) { + $id = $args[self::TAG]; + $this->calls[$id]['shortCircuited'] = true; + $this->finish($id, $pre, null); + } + + return $pre; + } + + private function ends(mixed $response, mixed $args, ?string $transport): void + { + if (is_array($args) && isset($this->calls[$args[self::TAG] ?? ''])) { + $this->finish($args[self::TAG], $response, $transport); + } + } + + private function finish(string $id, mixed $response, ?string $transport): void + { + $this->calls[$id]['end'] = microtime(true); + $this->calls[$id]['transport'] = $transport; + + if ($response instanceof \WP_Error) { + $this->calls[$id]['error'] = $response->get_error_message(); + + return; + } + + if (is_array($response)) { + $code = $response['response']['code'] ?? null; + $this->calls[$id]['status'] = is_int($code) || is_string($code) ? $code : null; + } + } +} diff --git a/src/Recording/LanguageRecorder.php b/src/Recording/LanguageRecorder.php new file mode 100644 index 0000000..7ae34c9 --- /dev/null +++ b/src/Recording/LanguageRecorder.php @@ -0,0 +1,59 @@ + + */ + private array $files = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_filter')) { + return; + } + + $this->installed = true; + + // WordPress 6.5+ asks for .l10n.php first, then .mo, through load_translation_file + add_filter('load_translation_file', function (mixed $file, mixed $domain): mixed { + $this->record((string) $domain, (string) $file, 'php / mo'); + + return $file; + }, PHP_INT_MAX, 2); + add_filter('load_script_translation_file', function (mixed $file, mixed $handle, mixed $domain): mixed { + if (is_string($file)) { + $this->record((string) $domain, $file, 'script ('.$handle.')'); + } + + return $file; + }, PHP_INT_MAX, 3); + } + + /** + * @return list + */ + public function files(): array + { + return $this->files; + } + + public function reset(): void + { + $this->files = []; + } + + private function record(string $domain, string $file, string $kind): void + { + $this->files[] = ['domain' => $domain, 'file' => $file, 'found' => $file !== '' && is_readable($file), 'kind' => $kind]; + } +} diff --git a/src/Recording/QueryTracer.php b/src/Recording/QueryTracer.php new file mode 100644 index 0000000..707c580 --- /dev/null +++ b/src/Recording/QueryTracer.php @@ -0,0 +1,145 @@ +installed || ! function_exists('add_filter')) { + return; + } + + $this->installed = true; + + add_filter('log_query_custom_data', fn (mixed $data): mixed => $this->trace($data), PHP_INT_MAX, 1); + add_filter('query', function (mixed $query): mixed { + $this->completePrevious(); + + return $query; + }, PHP_INT_MIN, 1); + } + + /** + * Copy the last query's error and rows, which no later query will do. + */ + public function completePrevious(): void + { + global $wpdb; + + if (! is_object($wpdb) || ! isset($wpdb->queries) || ! is_array($wpdb->queries) || $wpdb->queries === []) { + return; + } + + $index = array_key_last($wpdb->queries); + $data = $wpdb->queries[$index][4] ?? []; + + if (! is_array($data) || ! isset($data[self::KEY]) || array_key_exists('rows', $data[self::KEY])) { + return; + } + + $data[self::KEY]['error'] = is_string($wpdb->last_error ?? null) && $wpdb->last_error !== '' ? $wpdb->last_error : null; + $data[self::KEY]['rows'] = $this->rows($wpdb); + $wpdb->queries[$index][4] = $data; + } + + private function trace(mixed $data): mixed + { + global $wpdb; + + $data = is_array($data) ? $data : []; + $count = is_object($wpdb) && isset($wpdb->queries) && is_array($wpdb->queries) ? count($wpdb->queries) : 0; + + if ($count >= $this->softLimit) { + $data[self::KEY] = ['frames' => []]; + + return $data; + } + + // Attributed from the whole trace, shown from its innermost part: a + // page of WooCommerce runs a hundred queries, and full traces made + // most of the bar's weight + $frames = $this->frames(); + + $data[self::KEY] = [ + 'frames' => array_slice($frames, 0, self::SHOWN_FRAMES), + 'component' => $this->components?->ofTrace($frames), + ]; + + return $data; + } + + /** + * The calls that led to the query, innermost first, without `wpdb` and + * WordPress's hook plumbing. + * + * @return list + */ + private function frames(): array + { + $frames = []; + + foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, self::FRAMES + 8) as $frame) { + $class = $frame['class'] ?? ''; + + // The tracer itself, wpdb, and WordPress's hook plumbing say nothing about who asked + if ($class === self::class || is_a($class, 'wpdb', true) || $class === 'WP_Hook' || ! isset($frame['file']) + || str_ends_with($frame['file'], '/class-wpdb.php') || str_ends_with($frame['file'], '/wp-includes/plugin.php')) { + continue; + } + + $frames[] = [ + 'file' => $frame['file'], + 'line' => (int) ($frame['line'] ?? 0), + 'call' => ($class !== '' ? $class.($frame['type'] ?? '::') : '').$frame['function'], + ]; + + if (count($frames) >= self::FRAMES) { + break; + } + } + + return $frames; + } + + private function rows(object $wpdb): ?int + { + $rows = (int) ($wpdb->rows_affected ?? 0); + + return $rows > 0 ? $rows : (isset($wpdb->num_rows) ? (int) $wpdb->num_rows : null); + } +} diff --git a/src/Support/Components.php b/src/Support/Components.php new file mode 100644 index 0000000..7b037d1 --- /dev/null +++ b/src/Support/Components.php @@ -0,0 +1,187 @@ +|null + */ + private ?array $directories = null; + + /** + * @var array + */ + private array $cache = []; + + /** + * @param array|null $directories Directory => owner, longest first; detected when null + */ + public function __construct(?array $directories = null) + { + if ($directories !== null) { + uksort($directories, static fn (string $a, string $b): int => strlen($b) <=> strlen($a)); + $this->directories = $directories; + } + } + + /** + * The owner of a file, as a label: `plugin: woocommerce`, `theme: default`, `core`… + */ + public function of(string $file): string + { + if ($file === '') { + return 'unknown'; + } + + if (isset($this->cache[$file])) { + return $this->cache[$file]; + } + + $label = $this->label($this->owner($file)); + + // Not kept while WordPress has not defined its directories yet: the + // same file belongs to a plugin once it has + if ($this->directories !== null) { + $this->cache[$file] = $label; + } + + return $label; + } + + /** + * The owner of a backtrace, innermost frame first. + * + * The project's own code wins (plugin, mu-plugin, theme, module, app), as + * in Query Monitor; otherwise the innermost frame decides. A query + * WordPress makes while Pollora boots it is core's, not Pollora's. + * + * @param list $frames + */ + public function ofTrace(array $frames): string + { + $best = null; + $bestRank = PHP_INT_MAX; + $innermost = null; + + foreach ($frames as $frame) { + if (! isset($frame['file']) || $frame['file'] === '' || str_contains($frame['file'], '/pollora/debugbar/')) { + continue; + } + + $owner = $this->owner($frame['file']); + $innermost ??= $owner; + $rank = array_search($owner['kind'], self::PRIORITY, true); + + if ($rank !== false && $rank < $bestRank) { + $best = $owner; + $bestRank = $rank; + } + } + + $owner = $best ?? $innermost; + + return $owner === null ? 'unknown' : $this->label($owner); + } + + /** + * @return array{kind: string, name: string|null} + */ + private function owner(string $file): array + { + $file = str_replace('\\', '/', $file); + + foreach ($this->directories() as $directory => $owner) { + if (! str_starts_with($file, $directory)) { + continue; + } + + if ($owner['name'] !== null) { + return $owner; + } + + // Plugins, themes, modules and packages are named by their first directory + $rest = substr($file, strlen($directory)); + $segments = explode('/', $rest); + $name = $owner['kind'] === 'vendor' ? implode('/', array_slice($segments, 0, 2)) : $segments[0]; + + return ['kind' => $owner['kind'], 'name' => $owner['kind'] === 'plugin' && ! str_contains($rest, '/') ? basename($name, '.php') : $name]; + } + + return ['kind' => 'unknown', 'name' => null]; + } + + /** + * @param array{kind: string, name: string|null} $owner + */ + private function label(array $owner): string + { + return $owner['name'] === null || $owner['name'] === '' ? $owner['kind'] : "{$owner['kind']}: {$owner['name']}"; + } + + /** + * @return array + */ + private function directories(): array + { + if ($this->directories !== null) { + return $this->directories; + } + + $directories = []; + $add = static function (?string $path, string $kind, ?string $name = null) use (&$directories): void { + if ($path !== null && $path !== '') { + $directories[rtrim(str_replace('\\', '/', $path), '/').'/'] = ['kind' => $kind, 'name' => $name]; + } + }; + + $add(defined('WP_PLUGIN_DIR') ? WP_PLUGIN_DIR : null, 'plugin'); + $add(defined('WPMU_PLUGIN_DIR') ? WPMU_PLUGIN_DIR : null, 'mu-plugin', ''); + + if (function_exists('get_theme_root')) { + $add(get_theme_root(), 'theme'); + } + + // Outside a Laravel application (a unit test), only WordPress's directories are known + try { + $add(base_path('themes'), 'theme'); + $add(base_path('Modules'), 'module'); + $add(base_path('vendor/pollora'), 'pollora', ''); + $add(base_path('vendor/laravel'), 'laravel', ''); + $add(base_path('vendor'), 'vendor'); + $add(app_path(), 'app', ''); + } catch (\Throwable) { + } + + if (defined('ABSPATH')) { + $add(ABSPATH, 'core', ''); + } + + uksort($directories, static fn (string $a, string $b): int => strlen($b) <=> strlen($a)); + + // The first queries run before WordPress defines where plugins and + // themes live; keep looking until it has + if (defined('WP_PLUGIN_DIR') && function_exists('get_theme_root')) { + $this->directories = $directories; + } + + return $directories; + } +} diff --git a/tests/Feature/CollectorRegistrarTest.php b/tests/Feature/CollectorRegistrarTest.php index fc20153..2129179 100644 --- a/tests/Feature/CollectorRegistrarTest.php +++ b/tests/Feature/CollectorRegistrarTest.php @@ -17,7 +17,7 @@ function registrar(): CollectorRegistrar registrar()->register($debugbar); - foreach (['pollora', 'wp_request', 'wp_queries', 'wp_hooks', 'wp_timeline'] as $name) { + foreach (['pollora', 'pollora_doctor', 'wp_request', 'wp_queries', 'wp_hooks', 'wp_timeline', 'wp_http', 'wp_cache', 'wp_capabilities', 'wp_blocks', 'wp_assets', 'wp_languages'] as $name) { expect($debugbar->hasCollector($name))->toBeTrue(); } }); diff --git a/tests/Feature/DoctorTest.php b/tests/Feature/DoctorTest.php new file mode 100644 index 0000000..eb3c612 --- /dev/null +++ b/tests/Feature/DoctorTest.php @@ -0,0 +1,75 @@ +name; + } + + public function label(): string + { + return ucfirst($this->name); + } + + public function runsIn(): array + { + return [RunContext::Http]; + } + + public function run(RunContext $context): CheckResult + { + return $this->result; + } + }; +} + +beforeEach(function (): void { + $this->app['router']->get('_debugbar/pollora/doctor', DoctorController::class); + $this->app->instance(Doctor::class, new Doctor([ + check('cache', CheckResult::ok('Fine.')), + check('patterns', CheckResult::error('Broken pattern.', ['patterns/hero.php'], 'Fix the markup.')), + check('theme', CheckResult::warning('No build.')), + ])); +}); + +it('refuses callers outside local and private addresses, as Debugbar does for stored requests', function (): void { + // Testbench runs as "testing", where Debugbar keeps its storage closed + $this->getJson('_debugbar/pollora/doctor') + ->assertForbidden() + ->assertJsonPath('message', fn (string $message): bool => str_contains($message, 'debugbar.storage.open')); +}); + +it('runs the web checks and lists errors first', function (): void { + config(['debugbar.force_allow_enable' => true, 'debugbar.enabled' => true, 'debugbar.storage.open' => true]); + $this->collectingDebugbar(); + + $this->getJson('_debugbar/pollora/doctor') + ->assertOk() + ->assertJsonPath('checks.0.id', 'patterns') + ->assertJsonPath('checks.0.fix', 'Fix the markup.') + ->assertJsonPath('checks.0.details', ['patterns/hero.php']) + ->assertJsonPath('checks.1.status', 'warning') + ->assertJsonPath('checks.2.status', 'ok'); +}); + +it('gives its tab the address to call and a widget of its own, inlined', function (): void { + $collector = new DoctorCollector('/_debugbar/pollora/doctor'); + + expect($collector->collect()['data'])->toBe(['url' => '/_debugbar/pollora/doctor']) + ->and($collector->getWidgets()['pollora_doctor']['widget'])->toBe('PhpDebugBar.Widgets.PolloraDoctorWidget') + ->and($collector->getAssets()['inline_js']['pollora-doctor-widget'])->toContain('PolloraDoctorWidget'); +}); diff --git a/tests/Unit/Collectors/WpQueriesCollectorTest.php b/tests/Unit/Collectors/WpQueriesCollectorTest.php index 155b7a6..51a401b 100644 --- a/tests/Unit/Collectors/WpQueriesCollectorTest.php +++ b/tests/Unit/Collectors/WpQueriesCollectorTest.php @@ -4,6 +4,8 @@ use DebugBar\DataFormatter\DataFormatter; use Pollora\Debugbar\Collectors\WpQueriesCollector; +use Pollora\Debugbar\Recording\QueryTracer; +use Pollora\Debugbar\Support\Components; /** * WordPress keeps each query as [sql, seconds, caller, start, data] once @@ -62,3 +64,32 @@ function queriesFrom(WpQueriesCollector $collector): array expect(queriesFrom(new WpQueriesCollector)['nb_statements'])->toBe(0); }); + +it('uses the tracer\'s backtrace, error and rows, and groups queries by component', function (): void { + $GLOBALS['wpdb']->queries = [[ + 'SELECT * FROM wp_nope', 0.001, 'ignored caller string', 1.0, + [QueryTracer::KEY => [ + 'frames' => [ + ['file' => '/site/public/cms/wp-includes/option.php', 'line' => 12, 'call' => 'get_option'], + ['file' => '/site/public/content/plugins/acme/acme.php', 'line' => 30, 'call' => 'acme_boot'], + ], + 'error' => "Table 'wp_nope' doesn't exist", + 'rows' => 0, + ]], + ]]; + + $components = new Components([ + '/site/public/content/plugins/' => ['kind' => 'plugin', 'name' => null], + '/site/public/cms/' => ['kind' => 'core', 'name' => ''], + ]); + + $data = queriesFrom(new WpQueriesCollector(components: $components)); + $statement = $data['statements'][0]; + + expect($statement['connection'])->toBe('wpdb · plugin: acme') + ->and($statement['is_success'])->toBeFalse() + ->and($statement['error_message'])->toBe("Table 'wp_nope' doesn't exist") + ->and($statement['row_count'])->toBe(0) + ->and($statement['backtrace'][1])->toBe('acme_boot — /site/public/content/plugins/acme/acme.php:30') + ->and($data['nb_failed_statements'])->toBe(1); +}); diff --git a/tests/Unit/Recording/QueryTracerTest.php b/tests/Unit/Recording/QueryTracerTest.php new file mode 100644 index 0000000..7d63e72 --- /dev/null +++ b/tests/Unit/Recording/QueryTracerTest.php @@ -0,0 +1,85 @@ + + */ +function tracerHooks(QueryTracer $tracer): ArrayObject +{ + $hooks = new ArrayObject; + + foreach (['log_query_custom_data', 'query'] as $filter) { + Filters\expectAdded($filter)->zeroOrMoreTimes()->whenHappen(function (callable $callback) use ($hooks, $filter): void { + $hooks[$filter] = $callback; + }); + } + + $tracer->install(); + + return $hooks; +} + +beforeEach(function (): void { + $GLOBALS['wpdb'] = (object) ['queries' => [], 'last_error' => '', 'rows_affected' => 0, 'num_rows' => 0]; +}); + +afterEach(function (): void { + unset($GLOBALS['wpdb']); +}); + +it('adds the backtrace to the data WordPress saves with a query', function (): void { + $hooks = tracerHooks(new QueryTracer); + + $data = $hooks['log_query_custom_data']([]); + + expect($data[QueryTracer::KEY]['frames'])->not->toBeEmpty() + ->and($data[QueryTracer::KEY]['frames'][0])->toHaveKeys(['file', 'line', 'call']); +}); + +it('keeps no backtrace past the soft limit', function (): void { + $GLOBALS['wpdb']->queries = array_fill(0, 3, ['SELECT 1', 0.1, '', 0.0, []]); + $hooks = tracerHooks(new QueryTracer(softLimit: 2)); + + expect($hooks['log_query_custom_data']([])[QueryTracer::KEY]['frames'])->toBe([]); +}); + +it("copies the previous query's error and rows when the next one starts", function (): void { + $hooks = tracerHooks(new QueryTracer); + $GLOBALS['wpdb']->queries[] = ['SELECT nope', 0.1, '', 0.0, [QueryTracer::KEY => ['frames' => []]]]; + $GLOBALS['wpdb']->last_error = "Table 'nope' doesn't exist"; + $GLOBALS['wpdb']->num_rows = 0; + + $query = $hooks['query']('SELECT 2'); + + expect($query)->toBe('SELECT 2') + ->and($GLOBALS['wpdb']->queries[0][4][QueryTracer::KEY]['error'])->toBe("Table 'nope' doesn't exist") + ->and($GLOBALS['wpdb']->queries[0][4][QueryTracer::KEY]['rows'])->toBe(0); +}); + +it('completes a query once, so a later flush does not overwrite it', function (): void { + $tracer = new QueryTracer; + $GLOBALS['wpdb']->queries[] = ['UPDATE x', 0.1, '', 0.0, [QueryTracer::KEY => ['frames' => []]]]; + $GLOBALS['wpdb']->rows_affected = 3; + + $tracer->completePrevious(); + $GLOBALS['wpdb']->rows_affected = 0; + $GLOBALS['wpdb']->last_error = 'later'; + $tracer->completePrevious(); + + expect($GLOBALS['wpdb']->queries[0][4][QueryTracer::KEY])->toMatchArray(['rows' => 3, 'error' => null]); +}); + +it('attributes a query from its whole trace but keeps only the innermost frames to show', function (): void { + $components = new Components([dirname(__DIR__, 3).'/' => ['kind' => 'app', 'name' => '']]); + $hooks = tracerHooks(new QueryTracer(components: $components)); + + $data = $hooks['log_query_custom_data']([])[QueryTracer::KEY]; + + expect(count($data['frames']))->toBeLessThanOrEqual(10) + ->and($data['component'])->toBe('app'); +}); diff --git a/tests/Unit/Recording/RecordersTest.php b/tests/Unit/Recording/RecordersTest.php new file mode 100644 index 0000000..9ea7e59 --- /dev/null +++ b/tests/Unit/Recording/RecordersTest.php @@ -0,0 +1,187 @@ + $hooks + */ +function captured(array $hooks): ArrayObject +{ + $callbacks = new ArrayObject; + + foreach ($hooks as $hook) { + Filters\expectAdded($hook)->zeroOrMoreTimes()->whenHappen(function (callable $callback) use ($callbacks, $hook): void { + $callbacks[$hook] = $callback; + }); + Actions\expectAdded($hook)->zeroOrMoreTimes()->whenHappen(function (callable $callback) use ($callbacks, $hook): void { + $callbacks[$hook] = $callback; + }); + } + + return $callbacks; +} + +function noComponents(): Components +{ + return new Components([]); +} + +describe('HTTP calls', function (): void { + it('pairs each response with its request, even when a plugin answers in its place', function (): void { + $hooks = captured(['http_request_args', 'pre_http_request', 'http_api_debug']); + $recorder = new HttpRecorder; + $recorder->install(); + + $first = $hooks['http_request_args'](['method' => 'post'], 'https://api.example.test/orders'); + $second = $hooks['http_request_args'](['method' => 'GET'], 'https://api.example.test/stock'); + $hooks['pre_http_request'](['response' => ['code' => 200]], $second); + $hooks['http_api_debug'](['response' => ['code' => 201]], 'response', 'WpOrg\\Requests\\Requests', $first); + + $rows = (new WpHttpCollector($recorder, noComponents()))->setDataFormatter(new DataFormatter)->collect()['data']['data']; + + expect(array_keys($rows))->toBe(['1. POST https://api.example.test/orders', '2. GET https://api.example.test/stock']) + ->and($rows['1. POST https://api.example.test/orders']['result'])->toBe('201') + ->and($rows['1. POST https://api.example.test/orders']['transport'])->toBe('WpOrg\\Requests\\Requests') + ->and($rows['2. GET https://api.example.test/stock']['result'])->toBe('200 (answered by pre_http_request)'); + }); + + it('leaves a pre_http_request that lets the call through untouched', function (): void { + $hooks = captured(['http_request_args', 'pre_http_request', 'http_api_debug']); + $recorder = new HttpRecorder; + $recorder->install(); + + $args = $hooks['http_request_args']([], 'https://example.test'); + + expect($hooks['pre_http_request'](false, $args))->toBeFalse() + ->and($recorder->calls()[0]['end'])->toBeNull(); + }); +}); + +describe('capability checks', function (): void { + it('counts identical checks once, and tells granted from refused', function (): void { + $hooks = captured(['user_has_cap']); + $recorder = new CapabilityRecorder; + $recorder->install(); + + $allcaps = ['edit_posts' => true]; + foreach ([1, 2, 3] as $ignored) { + $hooks['user_has_cap']($allcaps, ['edit_posts'], ['edit_posts', 5]); + } + $returned = $hooks['user_has_cap']($allcaps, ['edit_others_posts'], ['edit_post', 5, 42]); + + $rows = (new WpCapabilitiesCollector($recorder, noComponents()))->collect()['data']['data']; + + expect($returned)->toBe($allcaps) + ->and($rows['edit_posts'])->toMatchArray(['result' => 'granted', 'user' => '#5', 'count' => 3]) + ->and($rows['edit_post(42)'])->toMatchArray(['result' => 'refused', 'count' => 1]); + }); + + it('refuses a check that maps to no capability, as WordPress does', function (): void { + $hooks = captured(['user_has_cap']); + $recorder = new CapabilityRecorder; + $recorder->install(); + + $hooks['user_has_cap'](['read' => true], [], ['do_not_allow', 0]); + + expect($recorder->checks()[0]['granted'])->toBeFalse(); + }); +}); + +describe('blocks', function (): void { + it('times each block type, counting inner blocks inside their parent', function (): void { + $hooks = captured(['pre_render_block', 'render_block']); + $recorder = new BlockRecorder; + $recorder->install(); + + $hooks['pre_render_block'](null, ['blockName' => 'core/group']); + $hooks['pre_render_block'](null, ['blockName' => 'core/paragraph']); + $hooks['render_block']('

a

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

b

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

cached

', ['blockName' => 'acme/cached']); + + expect($recorder->blocks())->toBe([]); + }); + + it('groups bindings by source, field and attribute', function (): void { + $recorder = new BlockRecorder; + $binding = fn (bool $cached, bool $hasValue): BindingResolved => new BindingResolved('acme/event', 'seats', 'content', 7, $cached ? 0.0 : 1.5, $cached, $hasValue); + $recorder->recordBinding($binding(false, true)); + $recorder->recordBinding($binding(true, true)); + $recorder->recordBinding($binding(false, false)); + + $rows = (new WpBlocksCollector($recorder))->setDataFormatter(new DataFormatter)->collect()['data']['data']; + + expect($rows['binding acme/event.seats → content'])->toMatchArray([ + 'count' => 3, + 'kind' => 'binding', + 'detail' => 'post 7, 1 from cache, 1 without value', + ]); + }); +}); + +describe('transients, languages and async actions', function (): void { + it('keeps the transients set, with their size and expiration', function (): void { + $hooks = captured(['set_transient', 'set_site_transient']); + $recorder = new CacheRecorder; + $recorder->install(); + + $hooks['set_transient']('acme_rates', ['eur' => 1.0], 3600); + $hooks['set_site_transient']('acme_feed', 'x', 0); + + expect($recorder->transients())->toHaveCount(2) + ->and($recorder->transients()[0])->toMatchArray(['name' => 'acme_rates', 'network' => false, 'expiration' => 3600, 'size' => strlen(serialize(['eur' => 1.0]))]) + ->and($recorder->transients()[1]['network'])->toBeTrue(); + }); + + it('keeps the translation files asked for, and whether they exist', function (): void { + $hooks = captured(['load_translation_file', 'load_script_translation_file']); + $recorder = new LanguageRecorder; + $recorder->install(); + + $returned = $hooks['load_translation_file'](__FILE__, 'acme'); + $hooks['load_translation_file']('/nowhere/acme-fr_FR.mo', 'acme'); + + expect($returned)->toBe(__FILE__) + ->and(array_column($recorder->files(), 'found'))->toBe([true, false]); + }); + + it('keeps the async actions the request queued', function (): void { + $hooks = captured(['pollora/async/dispatched']); + $recorder = new AsyncRecorder; + $recorder->install(); + + $hooks['pollora/async/dispatched']((object) ['hook' => 'save_post', 'handler' => 'App\\Sync@handle', 'driver' => 'queue'], 30); + + expect($recorder->dispatched())->toBe([['hook' => 'save_post', 'handler' => 'App\\Sync@handle', 'driver' => 'queue', 'delay' => 30]]); + }); +}); diff --git a/tests/Unit/Support/ComponentsTest.php b/tests/Unit/Support/ComponentsTest.php new file mode 100644 index 0000000..ed3fe6d --- /dev/null +++ b/tests/Unit/Support/ComponentsTest.php @@ -0,0 +1,63 @@ + ['kind' => 'plugin', 'name' => null], + '/site/public/content/mu-plugins/' => ['kind' => 'mu-plugin', 'name' => ''], + '/site/themes/' => ['kind' => 'theme', 'name' => null], + '/site/vendor/pollora/' => ['kind' => 'pollora', 'name' => ''], + '/site/vendor/' => ['kind' => 'vendor', 'name' => null], + '/site/app/' => ['kind' => 'app', 'name' => ''], + '/site/public/cms/' => ['kind' => 'core', 'name' => ''], + ]); +} + +it('names the plugin, theme or package a file belongs to', function (): void { + expect(components()->of('/site/public/content/plugins/woocommerce/includes/class-wc.php'))->toBe('plugin: woocommerce') + ->and(components()->of('/site/public/content/plugins/hello.php'))->toBe('plugin: hello') + ->and(components()->of('/site/themes/default/functions.php'))->toBe('theme: default') + ->and(components()->of('/site/vendor/spatie/laravel-data/src/Data.php'))->toBe('vendor: spatie/laravel-data') + ->and(components()->of('/site/vendor/pollora/framework/src/Foo.php'))->toBe('pollora') + ->and(components()->of('/site/public/cms/wp-includes/option.php'))->toBe('core') + ->and(components()->of('/elsewhere/file.php'))->toBe('unknown'); +}); + +it("gives a backtrace to the project's own code it passes through", function (): void { + $trace = [ + ['file' => '/site/public/cms/wp-includes/option.php'], + ['file' => '/site/vendor/pollora/framework/src/Foo.php'], + ['file' => '/site/public/content/plugins/woocommerce/includes/class-wc.php'], + ['file' => '/site/public/cms/wp-settings.php'], + ]; + + expect(components()->ofTrace($trace))->toBe('plugin: woocommerce'); +}); + +it('gives a backtrace through no project code to its innermost frame', function (): void { + $trace = [ + ['file' => '/site/public/cms/wp-includes/option.php'], + ['file' => '/site/vendor/pollora/framework/src/WordPress/Bootstrap.php'], + ]; + + expect(components()->ofTrace($trace))->toBe('core'); +}); + +it('prefers a plugin to a theme when both are in the trace', function (): void { + expect(components()->ofTrace([ + ['file' => '/site/themes/default/functions.php'], + ['file' => '/site/public/content/plugins/acme/acme.php'], + ]))->toBe('plugin: acme'); +}); + +it('keeps looking for directories until WordPress has defined where plugins live', function (): void { + // No directories given and no WordPress here: nothing can be known, and nothing is kept + $components = new Components; + + expect($components->of('/anywhere/plugins/acme/acme.php'))->toBe('unknown') + ->and((new ReflectionProperty(Components::class, 'directories'))->getValue($components))->toBeNull(); +}); diff --git a/tests/e2e/fixtures/acme-debugbar.php b/tests/e2e/fixtures/acme-debugbar.php index 29ea479..c0e6d15 100644 --- a/tests/e2e/fixtures/acme-debugbar.php +++ b/tests/e2e/fixtures/acme-debugbar.php @@ -22,6 +22,18 @@ add_action('init', function (): void { do_action('pollora/debugbar/message', 'Acme cart {id} rebuilt', 'info', ['id' => 'c-42']); do_action('qm/warning', 'Written for Query Monitor'); + + // An HTTP call answered in place, so the test needs no network + add_filter('pre_http_request', function ($pre, $args, $url) { + return str_starts_with($url, 'https://api.acme.test/') + ? ['headers' => [], 'body' => '{}', 'response' => ['code' => 200, 'message' => 'OK'], 'cookies' => [], 'filename' => null] + : $pre; + }, 10, 3); + wp_remote_get('https://api.acme.test/stock'); + + // WordPress only announces a transient whose value changed + set_transient('acme_rates', ['eur' => 1.0, 'at' => microtime(true)], HOUR_IN_SECONDS); + current_user_can('edit_posts'); }); if (class_exists(Collector::class)) { diff --git a/tests/e2e/specs/debugbar.spec.ts b/tests/e2e/specs/debugbar.spec.ts index be0df36..5fc935a 100644 --- a/tests/e2e/specs/debugbar.spec.ts +++ b/tests/e2e/specs/debugbar.spec.ts @@ -53,7 +53,7 @@ test('shows the queries WordPress ran and the hooks that fired', async ({ page } const data = firstDataset(await debugbar(page)); expect(data.wp_queries.nb_statements).toBeGreaterThan(0); - expect(data.wp_queries.statements[0].connection).toBe('wpdb'); + expect(data.wp_queries.statements[0].connection).toMatch(/^wpdb/); expect(data.wp_hooks.data.data.init.calls).toBeGreaterThan(0); }); @@ -82,3 +82,49 @@ test('shows what a plugin and a package add, labelled as theirs', async ({ page expect.arrayContaining(['Acme cart c-42 rebuilt', 'Written for Query Monitor']), ); }); + +test('groups WordPress queries by component and keeps their backtrace', async ({ page }) => { + await page.goto(''); + const statements = firstDataset(await debugbar(page)).wp_queries.statements; + + expect(statements.map((statement: any) => statement.connection)).toContain('wpdb · core'); + expect(statements[0].backtrace.length).toBeGreaterThan(0); +}); + +test('lists the HTTP calls, transients and capability checks of the request', async ({ page }) => { + await page.goto(''); + const data = firstDataset(await debugbar(page)); + + expect(data.wp_http.data.data['1. GET https://api.acme.test/stock'].result).toBe('200 (answered by pre_http_request)'); + expect(JSON.stringify(data.wp_cache.data['Transients set'])).toContain('acme_rates'); + expect(data.wp_capabilities.data.data.edit_posts.result).toBe('refused'); +}); + +test('shows the blocks of a post and the theme’s Vite build', async ({ page }) => { + await page.goto('hello-world/'); + const data = firstDataset(await debugbar(page)); + + expect(data.wp_blocks.data.data['core/paragraph'].count).toBeGreaterThan(0); + expect(data.wp_assets.data.data['vite: theme'].note).toBe('build (manifest)'); +}); + +test('keeps the request WordPress redirected from', async ({ page }) => { + await page.goto('?p=1'); + await expect(page).toHaveURL(/hello-world/); + + await expect.poll(async () => page.evaluate( + () => Object.values((window as any).phpdebugbar.datasets).map((dataset: any) => dataset.__meta?.uri), + )).toContain('/?p=1'); +}); + +test('runs the doctor on demand from its tab', async ({ page }) => { + await page.goto(''); + await debugbar(page); + + await page.evaluate(() => (window as any).phpdebugbar.showTab('pollora_doctor')); + const panel = page.locator('.phpdebugbar-panel.phpdebugbar-active'); + await panel.getByRole('button', { name: 'Run doctor' }).click(); + + await expect(panel.locator('tr').first()).toBeVisible(); + await expect(panel).toContainText(/\d+ (ok|warning|error)/); +});