diff --git a/CHANGELOG.md b/CHANGELOG.md index 94fe0da..7879bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas - `History\HistoryHandler` lists, reads and clears the request history with no MVC types. [#42](https://github.com/phalcon/debugbar/issues/42) - Request history in ADR applications, with the `history.base_uri` option. [#43](https://github.com/phalcon/debugbar/issues/43) - The `debug_page` option shows the debug page for a server error in ADR applications. [#48](https://github.com/phalcon/debugbar/issues/48) +- `Mvc\Bridge` and `ServiceBridge` feed the built-in collectors, which no longer hold framework event names. [#15](https://github.com/phalcon/debugbar/issues/15) +- `DebugBar::addCollector()` subscribes a collector that implements `Contracts\Subscriber`. [#15](https://github.com/phalcon/debugbar/issues/15) + +### Changed + +- **Breaking:** the built-in collectors no longer implement `Contracts\Subscriber` and have no `subscribe()` method. [#15](https://github.com/phalcon/debugbar/issues/15) ### Fixed diff --git a/docs/index.md b/docs/index.md index 170483a..ff0e9f8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -410,7 +410,7 @@ placement does not depend on request history. A collector reads its data in one of four ways: - **Snapshot** - reads state when the response is assembled. -- **Streamed** - subscribes to framework events and accumulates as they fire. +- **Streamed** - fed from framework events by a bridge, and accumulates as they fire. - **Manual** - fed through the `Debug` facade. - **Adapter** - captured from a `Phalcon\Logger` through the debug bar's logger adapter. @@ -430,7 +430,61 @@ A collector reads its data in one of four ways: | `time` | Total request time and named measures | manual + snapshot | | `view` | Rendered view paths and timings | streamed | -Streamed collectors read their data off the event source (the connection, view, router, or cache handed to them by the event). They never resolve a service from the container. +The bridges read the data off the event source (the connection, view, router, or cache handed to them by the event). They never resolve a service from the container. `Phalcon\DebugBar\ServiceBridge` handles the `db:*`, `view:*` and `cache:*` events in all applications. `Phalcon\DebugBar\Mvc\Bridge` and `Phalcon\DebugBar\ADR\Bridge` handle the events of their application type. + +## Custom Collectors + +To add a panel, extend `Phalcon\DebugBar\Collector\AbstractCollector` and set `NAME`, `$label` and `$panel`. `collect()` returns the data of the panel and the badge. To get data from events, also implement `Phalcon\DebugBar\Contracts\Subscriber` and attach your listeners in `subscribe()`. + +```php + + */ + private array $rows = []; + + public function collect(): array + { + return ['panel' => $this->rows, 'badge' => count($this->rows)]; + } + + public function subscribe(ManagerInterface $eventsManager): void + { + $eventsManager->attach( + 'orders:afterCreate', + function (EventInterface $event, mixed $source, mixed $id): void { + $this->rows[] = ['label' => 'Created', 'message' => is_string($id) ? $id : '']; + } + ); + } +} +``` + +Add the collector after the provider boots and before the response event. `getBar()` returns `null` when the bar did not boot, for example in a blocked environment. + +```php +addCollector(new OrdersCollector()); +``` + +`addCollector()` calls `subscribe()` when the bar has an events manager. A collector with no events does not implement `Subscriber`, and you add it the same way. A collector with the name of a built-in collector replaces it. The bridges do not feed the replacement, so it must attach its own listeners. ## Sharing the Events Manager @@ -488,7 +542,7 @@ try { | `startMeasure(string $name, ?string $label = null)` | `time` | Opens a named measure. | | `stopMeasure(string $name)` | `time` | Closes the named measure. | -The `exceptions` collector also captures throwables automatically. It subscribes to `dispatch:beforeException`, records the throwable, and leaves it to bubble - the bar never handles or swallows it. A throwable appears in the panel only when the request still finishes with a rendered response; a caught exception, or one the application forwards to an error page, qualifies, while a fully unhandled one aborts before the bar renders. +The `exceptions` collector also captures throwables automatically. In an MVC application, `Phalcon\DebugBar\Mvc\Bridge` listens to `dispatch:beforeException`, records the throwable, and leaves it to bubble - the bar never handles or swallows it. A throwable appears in the panel only when the request still finishes with a rendered response; a caught exception, or one the application forwards to an error page, qualifies, while a fully unhandled one aborts before the bar renders. ## Capturing Logs diff --git a/src/DebugBar/Collector/CacheCollector.php b/src/DebugBar/Collector/CacheCollector.php index 88cb237..b2da4b3 100644 --- a/src/DebugBar/Collector/CacheCollector.php +++ b/src/DebugBar/Collector/CacheCollector.php @@ -13,23 +13,17 @@ namespace Phalcon\DebugBar\Collector; -use Phalcon\DebugBar\Contracts\Subscriber; use Phalcon\DebugBar\DebugBarTypes; -use Phalcon\Events\EventInterface; -use Phalcon\Events\ManagerInterface; use function count; -use function is_string; /** - * Records cache operations by subscribing to the `cache:after*` events. The key - * is the event data, so it is read straight off the event - the `cache` service - * is never resolved. `addOperation()` records an operation with no events manager. + * Records cache operations. `addOperation()` takes the operation and the key. * * @phpstan-import-type list_envelope from DebugBarTypes * @phpstan-import-type list_panel from DebugBarTypes */ -final class CacheCollector extends AbstractCollector implements Subscriber +final class CacheCollector extends AbstractCollector { public const NAME = 'cache'; @@ -62,23 +56,4 @@ public function collect(): array 'badge' => count($this->operations), ]; } - - public function subscribe(ManagerInterface $eventsManager): void - { - $events = [ - 'cache:afterGet' => 'Get', - 'cache:afterSet' => 'Set', - 'cache:afterHas' => 'Has', - 'cache:afterDelete' => 'Delete', - ]; - - foreach ($events as $eventName => $operation) { - $eventsManager->attach( - $eventName, - function (EventInterface $event, mixed $cache, mixed $key) use ($operation): void { - $this->addOperation($operation, is_string($key) ? $key : ''); - } - ); - } - } } diff --git a/src/DebugBar/Collector/DatabaseCollector.php b/src/DebugBar/Collector/DatabaseCollector.php index 9027cb9..2b65c11 100644 --- a/src/DebugBar/Collector/DatabaseCollector.php +++ b/src/DebugBar/Collector/DatabaseCollector.php @@ -13,11 +13,7 @@ namespace Phalcon\DebugBar\Collector; -use Phalcon\Db\Adapter\AbstractAdapter; -use Phalcon\DebugBar\Contracts\Subscriber; use Phalcon\DebugBar\DebugBarTypes; -use Phalcon\Events\EventInterface; -use Phalcon\Events\ManagerInterface; use function count; use function hrtime; @@ -26,15 +22,13 @@ use function trim; /** - * Records SQL queries by subscribing to `db:beforeQuery`/`db:afterQuery`. The - * connection is the event's source, so the statement, bindings, and timing are - * read straight off it - no `db` service is resolved. `startQuery()` and - * `finishQuery()` record a query with no events manager. + * Records SQL queries. `startQuery()` starts the time, and `finishQuery()` takes + * the statement and the bindings. * * @phpstan-import-type db_query from DebugBarTypes * @phpstan-import-type database_envelope from DebugBarTypes */ -final class DatabaseCollector extends AbstractCollector implements Subscriber +final class DatabaseCollector extends AbstractCollector { use EncodesJson; use FormatsDuration; @@ -113,28 +107,6 @@ public function startQuery(): void $this->started = hrtime(true); } - public function subscribe(ManagerInterface $eventsManager): void - { - $eventsManager->attach( - 'db:beforeQuery', - function (): void { - $this->startQuery(); - } - ); - - $eventsManager->attach( - 'db:afterQuery', - function (EventInterface $event, mixed $connection): void { - if ($connection instanceof AbstractAdapter) { - $this->finishQuery( - $connection->getSQLStatement(), - $connection->getSQLVariables() - ); - } - } - ); - } - /** * Appends the bound parameters (e.g. `:APL0`) to the statement so the panel * shows the actual values sent to the database. diff --git a/src/DebugBar/Collector/ExceptionsCollector.php b/src/DebugBar/Collector/ExceptionsCollector.php index 17383bf..08bf5ac 100644 --- a/src/DebugBar/Collector/ExceptionsCollector.php +++ b/src/DebugBar/Collector/ExceptionsCollector.php @@ -14,10 +14,7 @@ namespace Phalcon\DebugBar\Collector; use Phalcon\DebugBar\Contracts\ExceptionAware; -use Phalcon\DebugBar\Contracts\Subscriber; use Phalcon\DebugBar\DebugBarTypes; -use Phalcon\Events\EventInterface; -use Phalcon\Events\ManagerInterface; use Throwable; use function count; @@ -25,15 +22,14 @@ use function substr; /** - * Records throwables - automatically via `dispatch:beforeException` (the - * throwable is only recorded, never swallowed) and manually through the `Debug` - * facade / DebugBar `addException()`. Each entry keeps a compact summary plus the - * stack trace; the full report remains the Debug page's job. + * Records throwables that `addThrowable()` receives, from a bridge or through the + * `Debug` facade / DebugBar `addException()`. Each entry keeps a compact summary + * plus the stack trace; the full report remains the Debug page's job. * * @phpstan-import-type exception_envelope from DebugBarTypes * @phpstan-import-type exception_panel from DebugBarTypes */ -final class ExceptionsCollector extends AbstractCollector implements ExceptionAware, Subscriber +final class ExceptionsCollector extends AbstractCollector implements ExceptionAware { public const NAME = 'exceptions'; @@ -69,22 +65,6 @@ public function collect(): array ]; } - /** - * Auto-captures dispatch exceptions. The throwable is recorded and left to - * bubble - the bar never handles or swallows it. - */ - public function subscribe(ManagerInterface $eventsManager): void - { - $eventsManager->attach( - 'dispatch:beforeException', - function (EventInterface $event, mixed $dispatcher, mixed $exception): void { - if ($exception instanceof Throwable) { - $this->addThrowable($exception); - } - } - ); - } - private function shortClass(string $class): string { $position = strrpos($class, '\\'); diff --git a/src/DebugBar/Collector/RouteCollector.php b/src/DebugBar/Collector/RouteCollector.php index 3902955..b49b3e8 100644 --- a/src/DebugBar/Collector/RouteCollector.php +++ b/src/DebugBar/Collector/RouteCollector.php @@ -14,26 +14,17 @@ namespace Phalcon\DebugBar\Collector; use Closure; -use Phalcon\DebugBar\Contracts\Subscriber; use Phalcon\DebugBar\DebugBarTypes; -use Phalcon\Events\EventInterface; -use Phalcon\Events\ManagerInterface; -use Phalcon\Mvc\RouterInterface; /** - * Reports the matched route. The router is the source of `router:matchedRoute`, - * so it is captured there and read at `collect()` - by response time it has been - * fully resolved (module/controller/action/params), which is not yet true when - * the event fires. Nothing is resolved from the container. `setRoute()` gives the - * panel values with no events manager. + * Reports the matched route. `setRoute()` takes the panel values, or a closure + * that returns them when the values are not final until `collect()`. * * @phpstan-import-type grid_envelope from DebugBarTypes * @phpstan-import-type grid_panel from DebugBarTypes */ -final class RouteCollector extends AbstractCollector implements Subscriber +final class RouteCollector extends AbstractCollector { - use EncodesJson; - public const NAME = 'route'; protected string $icon = 'icon-route'; @@ -68,24 +59,4 @@ public function setRoute(array | Closure $route): void { $this->route = $route; } - - public function subscribe(ManagerInterface $eventsManager): void - { - $eventsManager->attach( - 'router:matchedRoute', - function (EventInterface $event, mixed $router): void { - if ($router instanceof RouterInterface) { - $this->setRoute( - fn (): array => [ - 'Module' => $router->getModuleName(), - 'Namespace' => $router->getNamespaceName(), - 'Controller' => $router->getControllerName(), - 'Action' => $router->getActionName(), - 'Params' => $this->jsonOrEmpty($router->getParams()), - ] - ); - } - } - ); - } } diff --git a/src/DebugBar/Collector/ViewCollector.php b/src/DebugBar/Collector/ViewCollector.php index 16e044d..3106c78 100644 --- a/src/DebugBar/Collector/ViewCollector.php +++ b/src/DebugBar/Collector/ViewCollector.php @@ -13,28 +13,22 @@ namespace Phalcon\DebugBar\Collector; -use Phalcon\DebugBar\Contracts\Subscriber; use Phalcon\DebugBar\DebugBarTypes; -use Phalcon\Events\EventInterface; -use Phalcon\Events\ManagerInterface; use function array_pop; use function count; use function hrtime; -use function is_string; /** - * Records rendered views by subscribing to `view:beforeRenderView`/ - * `view:afterRenderView`. The path is the event data on the "before" event, so - * nothing is resolved from the container. A stack pairs each "before" with its - * matching "after", so nested partials are timed correctly. `startRender()` and - * `finishRender()` record a render with no events manager. + * Records rendered views. `startRender()` takes the path and `finishRender()` ends + * the last render that started. A stack pairs each start with its finish, so nested + * partials are timed correctly. * * @phpstan-import-type list_envelope from DebugBarTypes * @phpstan-import-type view_pending from DebugBarTypes * @phpstan-import-type view_render from DebugBarTypes */ -final class ViewCollector extends AbstractCollector implements Subscriber +final class ViewCollector extends AbstractCollector { use FormatsDuration; @@ -99,21 +93,4 @@ public function startRender(string $path): void 'start' => hrtime(true), ]; } - - public function subscribe(ManagerInterface $eventsManager): void - { - $eventsManager->attach( - 'view:beforeRenderView', - function (EventInterface $event, mixed $view, mixed $path): void { - $this->startRender(is_string($path) ? $path : ''); - } - ); - - $eventsManager->attach( - 'view:afterRenderView', - function (): void { - $this->finishRender(); - } - ); - } } diff --git a/src/DebugBar/Contracts/Subscriber.php b/src/DebugBar/Contracts/Subscriber.php index dc5cfb9..6aeae56 100644 --- a/src/DebugBar/Contracts/Subscriber.php +++ b/src/DebugBar/Contracts/Subscriber.php @@ -16,9 +16,9 @@ use Phalcon\Events\ManagerInterface; /** - * Implemented by a collector that gathers its data from framework events. At - * boot the provider hands each such collector the application's EventsManager - * so it can attach its own listeners. + * A custom collector implements this to attach its own listeners. `DebugBar::addCollector()` + * calls `subscribe()` when the bar has an events manager. The built-in collectors + * do not use it: the bridges feed them. */ interface Subscriber { diff --git a/src/DebugBar/Core.php b/src/DebugBar/Core.php index ca445f6..b69d381 100644 --- a/src/DebugBar/Core.php +++ b/src/DebugBar/Core.php @@ -30,7 +30,6 @@ use Phalcon\DebugBar\Collector\ViewCollector; use Phalcon\DebugBar\Contracts\Collector; use Phalcon\DebugBar\Contracts\History; -use Phalcon\DebugBar\Contracts\Subscriber; use Phalcon\DebugBar\Exceptions\CannotUseInProduction; use Phalcon\DebugBar\History\HistoryCookie; use Phalcon\DebugBar\History\HistoryEndpoint; @@ -107,8 +106,8 @@ public function __construct(array $config = []) /** * Creates the bar with its enabled collectors, points the `Debug` facade at - * it and subscribes the streamed collectors. It returns the listener, or null - * when the bar cannot boot. + * it and subscribes `ServiceBridge`. It returns the listener, or null when the + * bar cannot boot. * * @throws CannotUseInProduction When `env.strict` is set and the environment is blocked or undefined. */ @@ -124,7 +123,7 @@ public function boot( return null; } - $bar = new DebugBar(); + $bar = new DebugBar($eventsManager); foreach ($this->buildCollectors($request, $config) as $collector) { $bar->addCollector($collector); } @@ -135,11 +134,7 @@ public function boot( Debug::setBar($bar); if (null !== $eventsManager) { - foreach ($bar->getCollectors() as $collector) { - if ($collector instanceof Subscriber) { - $collector->subscribe($eventsManager); - } - } + (new ServiceBridge($bar))->subscribe($eventsManager); } $this->listener = new ResponseListener( diff --git a/src/DebugBar/DebugBar.php b/src/DebugBar/DebugBar.php index fc071fb..a9b0e60 100644 --- a/src/DebugBar/DebugBar.php +++ b/src/DebugBar/DebugBar.php @@ -18,8 +18,10 @@ use Phalcon\DebugBar\Contracts\LoggerAware; use Phalcon\DebugBar\Contracts\MessageAware; use Phalcon\DebugBar\Contracts\Renderable; +use Phalcon\DebugBar\Contracts\Subscriber; use Phalcon\DebugBar\Contracts\TimeAware; use Phalcon\DebugBar\Exceptions\Exception; +use Phalcon\Events\ManagerInterface; use Throwable; use function count; @@ -52,13 +54,23 @@ class DebugBar protected ?Throwable $lastException = null; + public function __construct(protected ?ManagerInterface $eventsManager = null) + { + } + /** - * Registers (or replaces) a collector under its name. + * Registers (or replaces) a collector under its name. A collector that + * implements `Subscriber` attaches its listeners when the bar has an events + * manager. */ public function addCollector(Collector $collector): static { $this->collectors[$collector->getName()] = $collector; + if (null !== $this->eventsManager && $collector instanceof Subscriber) { + $collector->subscribe($this->eventsManager); + } + return $this; } diff --git a/src/DebugBar/Mvc/Bridge.php b/src/DebugBar/Mvc/Bridge.php new file mode 100644 index 0000000..a2563a4 --- /dev/null +++ b/src/DebugBar/Mvc/Bridge.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\DebugBar\Mvc; + +use Phalcon\DebugBar\Collector\EncodesJson; +use Phalcon\DebugBar\Collector\ExceptionsCollector; +use Phalcon\DebugBar\Collector\RouteCollector; +use Phalcon\DebugBar\DebugBar; +use Phalcon\Events\EventInterface; +use Phalcon\Events\ManagerInterface; +use Phalcon\Mvc\RouterInterface; +use Throwable; + +/** + * Feeds the collectors from the events of an MVC application. The router is the + * source of the route event, and this class reads it at `collect()`: the router is + * not fully resolved when the event fires. An exception is only recorded: it + * continues to the application, and the bar does not handle it. + */ +final class Bridge +{ + use EncodesJson; + + public function __construct(private readonly DebugBar $bar) + { + } + + public function subscribe(ManagerInterface $eventsManager): void + { + $collectors = $this->bar->getCollectors(); + + $exceptions = $collectors[ExceptionsCollector::NAME] ?? null; + if ($exceptions instanceof ExceptionsCollector) { + $this->subscribeExceptions($eventsManager, $exceptions); + } + + $route = $collectors[RouteCollector::NAME] ?? null; + if ($route instanceof RouteCollector) { + $this->subscribeRoute($eventsManager, $route); + } + } + + private function subscribeExceptions(ManagerInterface $eventsManager, ExceptionsCollector $collector): void + { + $eventsManager->attach( + 'dispatch:beforeException', + function (EventInterface $event, mixed $dispatcher, mixed $exception) use ($collector): void { + if ($exception instanceof Throwable) { + $collector->addThrowable($exception); + } + } + ); + } + + private function subscribeRoute(ManagerInterface $eventsManager, RouteCollector $collector): void + { + $eventsManager->attach( + 'router:matchedRoute', + function (EventInterface $event, mixed $router) use ($collector): void { + if ($router instanceof RouterInterface) { + $collector->setRoute( + fn (): array => [ + 'Module' => $router->getModuleName(), + 'Namespace' => $router->getNamespaceName(), + 'Controller' => $router->getControllerName(), + 'Action' => $router->getActionName(), + 'Params' => $this->jsonOrEmpty($router->getParams()), + ] + ); + } + } + ); + } +} diff --git a/src/DebugBar/Provider.php b/src/DebugBar/Provider.php index baae211..6938b45 100644 --- a/src/DebugBar/Provider.php +++ b/src/DebugBar/Provider.php @@ -22,6 +22,7 @@ use Phalcon\DebugBar\History\HistoryCookie; use Phalcon\DebugBar\History\HistoryEndpoint; use Phalcon\DebugBar\History\HistoryOptions; +use Phalcon\DebugBar\Mvc\Bridge; use Phalcon\DebugBar\Mvc\HistoryListener; use Phalcon\DebugBar\Security\AccessGate; use Phalcon\Di\DiInterface; @@ -84,11 +85,14 @@ public function boot(): void $this->historyCookie ); - if (null === $eventsManager || null === $listener) { + $bar = Debug::getBar(); + + if (null === $eventsManager || null === $listener || null === $bar) { return; } $eventsManager->attach('application:beforeSendResponse', $listener); + (new Bridge($bar))->subscribe($eventsManager); } /** diff --git a/src/DebugBar/ServiceBridge.php b/src/DebugBar/ServiceBridge.php new file mode 100644 index 0000000..5a5e43b --- /dev/null +++ b/src/DebugBar/ServiceBridge.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\DebugBar; + +use Phalcon\Db\Adapter\AbstractAdapter; +use Phalcon\DebugBar\Collector\CacheCollector; +use Phalcon\DebugBar\Collector\DatabaseCollector; +use Phalcon\DebugBar\Collector\ViewCollector; +use Phalcon\Events\EventInterface; +use Phalcon\Events\ManagerInterface; + +use function is_string; + +/** + * Feeds the collectors from the events of the Phalcon services: `db:*`, `view:*` + * and `cache:*`. These services fire their events in any application, so `Core` + * subscribes this class. The data comes from the event: no service is resolved. + * A `before` listener must not return `false`: this cancels the operation. + */ +final class ServiceBridge +{ + private const CACHE_EVENTS = [ + 'cache:afterGet' => 'Get', + 'cache:afterSet' => 'Set', + 'cache:afterHas' => 'Has', + 'cache:afterDelete' => 'Delete', + ]; + + public function __construct(private readonly DebugBar $bar) + { + } + + public function subscribe(ManagerInterface $eventsManager): void + { + $collectors = $this->bar->getCollectors(); + + $cache = $collectors[CacheCollector::NAME] ?? null; + if ($cache instanceof CacheCollector) { + $this->subscribeCache($eventsManager, $cache); + } + + $database = $collectors[DatabaseCollector::NAME] ?? null; + if ($database instanceof DatabaseCollector) { + $this->subscribeDatabase($eventsManager, $database); + } + + $view = $collectors[ViewCollector::NAME] ?? null; + if ($view instanceof ViewCollector) { + $this->subscribeView($eventsManager, $view); + } + } + + private function subscribeCache(ManagerInterface $eventsManager, CacheCollector $collector): void + { + foreach (self::CACHE_EVENTS as $eventName => $operation) { + $eventsManager->attach( + $eventName, + function (EventInterface $event, mixed $cache, mixed $key) use ($collector, $operation): void { + $collector->addOperation($operation, is_string($key) ? $key : ''); + } + ); + } + } + + private function subscribeDatabase(ManagerInterface $eventsManager, DatabaseCollector $collector): void + { + $eventsManager->attach( + 'db:beforeQuery', + function () use ($collector): void { + $collector->startQuery(); + } + ); + + $eventsManager->attach( + 'db:afterQuery', + function (EventInterface $event, mixed $connection) use ($collector): void { + if ($connection instanceof AbstractAdapter) { + $collector->finishQuery( + $connection->getSQLStatement(), + $connection->getSQLVariables() + ); + } + } + ); + } + + private function subscribeView(ManagerInterface $eventsManager, ViewCollector $collector): void + { + $eventsManager->attach( + 'view:beforeRenderView', + function (EventInterface $event, mixed $view, mixed $path) use ($collector): void { + $collector->startRender(is_string($path) ? $path : ''); + } + ); + + $eventsManager->attach( + 'view:afterRenderView', + function () use ($collector): void { + $collector->finishRender(); + } + ); + } +} diff --git a/tests/Unit/DebugBar/ADR/ProviderTest.php b/tests/Unit/DebugBar/ADR/ProviderTest.php index e58ce16..7934ef9 100644 --- a/tests/Unit/DebugBar/ADR/ProviderTest.php +++ b/tests/Unit/DebugBar/ADR/ProviderTest.php @@ -17,6 +17,7 @@ use Phalcon\Container\Container; use Phalcon\Contracts\Events\Manager as ManagerContract; use Phalcon\Contracts\Http\AttributeRequest; +use Phalcon\Db\Adapter\AbstractAdapter; use Phalcon\DebugBar\ADR\Provider; use Phalcon\DebugBar\Debug; use Phalcon\DebugBar\DebugBar; @@ -32,6 +33,7 @@ use Phalcon\Tests\Support\DebugBar\ADR\Action\View\GetView; use Phalcon\Tests\Support\DebugBar\ADR\Front; use Phalcon\Tests\Support\DebugBar\ADR\RecordingEmitter; +use Phalcon\Tests\Support\DebugBar\Fixtures\SubscribingCollector; use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\RequiresPhpExtension; @@ -140,6 +142,41 @@ public function testContainerWithNoServicesAttachesNothing(): void $this->assertSame([], $this->bootedBar()->collect()['data']['request']['panel']); } + public function testCustomCollectorGetsItsEvents(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $manager = $this->container()->get(ManagerContract::class); + if (!$manager instanceof ManagerInterface) { + $this->fail('Expected the default events manager.'); + } + + $collector = new SubscribingCollector(); + Debug::getBar()?->addCollector($collector); + $manager->fire(SubscribingCollector::EVENT, $this, 'happened'); + + $this->assertSame([['message' => 'happened']], $collector->collect()['panel']); + } + + public function testDbEventsOfAPhalconAdapterReachTheDatabasePanel(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $manager = $this->container()->get(ManagerContract::class); + if (!$manager instanceof ManagerInterface) { + $this->fail('Expected the default events manager.'); + } + + $connection = $this->createMock(AbstractAdapter::class); + $connection->method('getSQLStatement')->willReturn('SELECT 1'); + $connection->method('getSQLVariables')->willReturn([]); + + $manager->fire('db:beforeQuery', $connection); + $manager->fire('db:afterQuery', $connection); + + $this->assertSame(1, $this->bootedBar()->collect()['data']['database']['badge']); + } + public function testDoesNotInjectIntoAJsonResponse(): void { $_ENV[self::ENV_VAR] = 'dev'; diff --git a/tests/Unit/DebugBar/Collector/CacheCollectorTest.php b/tests/Unit/DebugBar/Collector/CacheCollectorTest.php index 5f46c96..e54fe58 100644 --- a/tests/Unit/DebugBar/Collector/CacheCollectorTest.php +++ b/tests/Unit/DebugBar/Collector/CacheCollectorTest.php @@ -14,7 +14,6 @@ namespace Phalcon\Tests\Unit\DebugBar\Collector; use Phalcon\DebugBar\Collector\CacheCollector; -use Phalcon\Events\Manager; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; use Phalcon\Tests\Support\DebugBar\PanelContractTrait; @@ -34,23 +33,6 @@ public function testAddOperationRecordsWithNoEventsManager(): void $this->assertSame([['label' => 'Get', 'message' => 'user_42']], $envelope['panel']); } - public function testCacheOperationsAreRecordedFromEvents(): void - { - $collector = new CacheCollector(); - $eventsManager = new Manager(); - $collector->subscribe($eventsManager); - - $eventsManager->fire('cache:afterGet', $this, 'user_42'); - $eventsManager->fire('cache:afterSet', $this, 'user_42'); - - $envelope = $collector->collect(); - - $this->assertSame(2, $envelope['badge']); - $this->assertSame('Get', $envelope['panel'][0]['label']); - $this->assertSame('user_42', $envelope['panel'][0]['message']); - $this->assertSame('Set', $envelope['panel'][1]['label']); - } - public function testNameAndPanelContract(): void { $collector = new CacheCollector(); diff --git a/tests/Unit/DebugBar/Collector/DatabaseCollectorTest.php b/tests/Unit/DebugBar/Collector/DatabaseCollectorTest.php index 4487d91..0da99a4 100644 --- a/tests/Unit/DebugBar/Collector/DatabaseCollectorTest.php +++ b/tests/Unit/DebugBar/Collector/DatabaseCollectorTest.php @@ -13,39 +13,15 @@ namespace Phalcon\Tests\Unit\DebugBar\Collector; -use Phalcon\Db\Adapter\AbstractAdapter; use Phalcon\DebugBar\Collector\DatabaseCollector; -use Phalcon\Events\Manager; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; use Phalcon\Tests\Support\DebugBar\PanelContractTrait; use ReflectionProperty; -use stdClass; final class DatabaseCollectorTest extends AbstractUnitTestCase { use PanelContractTrait; - public function testBindingsAreAppendedToTheQuery(): void - { - $collector = new DatabaseCollector(); - $eventsManager = new Manager(); - $collector->subscribe($eventsManager); - - $connection = $this->createMock(AbstractAdapter::class); - $connection->method('getSQLStatement')->willReturn('SELECT * FROM users WHERE id = :APL0'); - $connection->method('getSQLVariables')->willReturn(['APL0' => 5]); - - $eventsManager->fire('db:beforeQuery', $connection); - $eventsManager->fire('db:afterQuery', $connection); - - $message = $collector->collect()['panel'][0]['message']; - - $this->assertSame( - 'SELECT * FROM users WHERE id = :APL0 {"APL0":5}', - $message - ); - } - public function testDuplicateDetectionIgnoresBindingsButNotDifferentStatements(): void { $collector = new DatabaseCollector(); @@ -79,36 +55,6 @@ public function testDuplicateDetectionIgnoresBindingsButNotDifferentStatements() $this->assertSame([3, 3, 3, null, null], array_column($envelope['panel'], 'occurrences')); } - public function testDurationIsMeasuredBetweenBeforeAndAfterQuery(): void - { - $collector = new DatabaseCollector(); - $eventsManager = new Manager(); - $collector->subscribe($eventsManager); - - $connection = $this->createMock(AbstractAdapter::class); - $connection->method('getSQLStatement')->willReturn('SELECT 1'); - $connection->method('getSQLVariables')->willReturn([]); - - $eventsManager->fire('db:beforeQuery', $connection); - $eventsManager->fire('db:afterQuery', $connection); - - $property = new ReflectionProperty(DatabaseCollector::class, 'queries'); - $property->setAccessible(true); - - /** @var array $queries */ - $queries = $property->getValue($collector); - $time = $queries[0]['time']; - - /** - * The delta between two consecutive event fires is a handful of - * nanoseconds. If `db:beforeQuery` never seeded `started`, or the - * subtraction became an addition, `time` would carry the full monotonic - * clock value (billions of nanoseconds) instead. - */ - $this->assertGreaterThanOrEqual(0, $time); - $this->assertLessThan(1000000000, $time); - } - public function testEmptyCollectorHasZeroSummaryValues(): void { $envelope = (new DatabaseCollector())->collect(); @@ -185,36 +131,6 @@ public function testNameAndPanelContract(): void $this->assertPanelContract($collector); } - public function testNonAdapterConnectionIsIgnored(): void - { - $collector = new DatabaseCollector(); - $eventsManager = new Manager(); - $collector->subscribe($eventsManager); - - $eventsManager->fire('db:afterQuery', new stdClass()); - - $this->assertSame(0, $collector->collect()['badge']); - } - - public function testQueriesAreRecordedFromEvents(): void - { - $collector = new DatabaseCollector(); - $eventsManager = new Manager(); - $collector->subscribe($eventsManager); - - $connection = $this->createMock(AbstractAdapter::class); - $connection->method('getSQLStatement')->willReturn('SELECT 1'); - $connection->method('getSQLVariables')->willReturn([]); - - $eventsManager->fire('db:beforeQuery', $connection); - $eventsManager->fire('db:afterQuery', $connection); - - $envelope = $collector->collect(); - - $this->assertSame(1, $envelope['badge']); - $this->assertSame('SELECT 1', $envelope['panel'][0]['message']); - } - public function testStartAndFinishRecordAQueryWithNoEventsManager(): void { $collector = new DatabaseCollector(); diff --git a/tests/Unit/DebugBar/Collector/ExceptionsCollectorTest.php b/tests/Unit/DebugBar/Collector/ExceptionsCollectorTest.php index b089f0f..6e49796 100644 --- a/tests/Unit/DebugBar/Collector/ExceptionsCollectorTest.php +++ b/tests/Unit/DebugBar/Collector/ExceptionsCollectorTest.php @@ -15,7 +15,6 @@ use Phalcon\DebugBar\Collector\ExceptionsCollector; use Phalcon\DebugBar\Exceptions\CannotUseInProduction; -use Phalcon\Events\Manager; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; use Phalcon\Tests\Support\DebugBar\PanelContractTrait; use RuntimeException; @@ -24,20 +23,6 @@ final class ExceptionsCollectorTest extends AbstractUnitTestCase { use PanelContractTrait; - public function testDispatchExceptionsAreAutoRecorded(): void - { - $collector = new ExceptionsCollector(); - $eventsManager = new Manager(); - $collector->subscribe($eventsManager); - - $eventsManager->fire('dispatch:beforeException', $this, new RuntimeException('dispatched')); - - $envelope = $collector->collect(); - - $this->assertSame(1, $envelope['badge']); - $this->assertStringContainsString('dispatched', $envelope['panel'][0]['message']); - } - public function testNameAndPanelContract(): void { $collector = new ExceptionsCollector(); @@ -57,17 +42,6 @@ public function testNamespacedThrowableLabelIsShortClass(): void $this->assertSame('CannotUseInProduction', $envelope['panel'][0]['label']); } - public function testNonThrowableEventPayloadIsIgnored(): void - { - $collector = new ExceptionsCollector(); - $eventsManager = new Manager(); - $collector->subscribe($eventsManager); - - $eventsManager->fire('dispatch:beforeException', $this, 'not-a-throwable'); - - $this->assertSame(0, $collector->collect()['badge']); - } - public function testThrowableMessageFormat(): void { $collector = new ExceptionsCollector(); diff --git a/tests/Unit/DebugBar/Collector/RouteCollectorTest.php b/tests/Unit/DebugBar/Collector/RouteCollectorTest.php index 6b6b3b0..39e36f8 100644 --- a/tests/Unit/DebugBar/Collector/RouteCollectorTest.php +++ b/tests/Unit/DebugBar/Collector/RouteCollectorTest.php @@ -14,11 +14,8 @@ namespace Phalcon\Tests\Unit\DebugBar\Collector; use Phalcon\DebugBar\Collector\RouteCollector; -use Phalcon\Events\Manager; -use Phalcon\Mvc\RouterInterface; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; use Phalcon\Tests\Support\DebugBar\PanelContractTrait; -use stdClass; final class RouteCollectorTest extends AbstractUnitTestCase { @@ -32,17 +29,6 @@ public function testNameAndPanelContract(): void $this->assertPanelContract($collector); } - public function testNonRouterSourceIsIgnored(): void - { - $collector = new RouteCollector(); - $eventsManager = new Manager(); - $collector->subscribe($eventsManager); - - $eventsManager->fire('router:matchedRoute', new stdClass(), null); - - $this->assertSame([], $collector->collect()['panel']); - } - public function testRouteClosureIsCalledAtCollectTime(): void { $collector = new RouteCollector(); @@ -58,51 +44,6 @@ function () use (&$action): array { $this->assertSame(['Action' => 'after'], $collector->collect()['panel']); } - public function testRouteIsSnapshotFromTheRouter(): void - { - $collector = new RouteCollector(); - $eventsManager = new Manager(); - $collector->subscribe($eventsManager); - - $router = $this->createMock(RouterInterface::class); - $router->method('getModuleName')->willReturn('frontend'); - $router->method('getNamespaceName')->willReturn('App\Controllers'); - $router->method('getControllerName')->willReturn('session'); - $router->method('getActionName')->willReturn('login'); - $router->method('getParams')->willReturn(['id' => 5]); - - $eventsManager->fire('router:matchedRoute', $router, null); - - $panel = $collector->collect()['panel']; - - $this->assertSame('frontend', $panel['Module']); - $this->assertArrayHasKey('Namespace', $panel); - $this->assertSame('App\Controllers', $panel['Namespace']); - $this->assertSame('session', $panel['Controller']); - $this->assertSame('login', $panel['Action']); - $this->assertSame('{"id":5}', $panel['Params']); - } - - public function testRouterIsReadAtCollectTime(): void - { - $collector = new RouteCollector(); - $eventsManager = new Manager(); - $collector->subscribe($eventsManager); - - $action = 'before'; - $router = $this->createMock(RouterInterface::class); - $router->method('getActionName')->willReturnCallback( - function () use (&$action): string { - return $action; - } - ); - - $eventsManager->fire('router:matchedRoute', $router, null); - $action = 'after'; - - $this->assertSame('after', $collector->collect()['panel']['Action']); - } - public function testRouteValuesAreShownInThePanel(): void { $collector = new RouteCollector(); diff --git a/tests/Unit/DebugBar/Collector/ViewCollectorTest.php b/tests/Unit/DebugBar/Collector/ViewCollectorTest.php index 94bddf3..d344654 100644 --- a/tests/Unit/DebugBar/Collector/ViewCollectorTest.php +++ b/tests/Unit/DebugBar/Collector/ViewCollectorTest.php @@ -14,7 +14,6 @@ namespace Phalcon\Tests\Unit\DebugBar\Collector; use Phalcon\DebugBar\Collector\ViewCollector; -use Phalcon\Events\Manager; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; use Phalcon\Tests\Support\DebugBar\PanelContractTrait; use ReflectionProperty; @@ -77,21 +76,6 @@ public function testNestedRendersPairWithTheirOwnStart(): void ); } - public function testRenderedViewsAreRecordedFromEvents(): void - { - $collector = new ViewCollector(); - $eventsManager = new Manager(); - $collector->subscribe($eventsManager); - - $eventsManager->fire('view:beforeRenderView', $this, '/app/views/index.phtml'); - $eventsManager->fire('view:afterRenderView', $this); - - $envelope = $collector->collect(); - - $this->assertSame(1, $envelope['badge']); - $this->assertSame('/app/views/index.phtml', $envelope['panel'][0]['message']); - } - public function testStartAndFinishRecordARenderWithNoEventsManager(): void { $collector = new ViewCollector(); @@ -104,40 +88,4 @@ public function testStartAndFinishRecordARenderWithNoEventsManager(): void $this->assertSame(1, $envelope['badge']); $this->assertSame('/app/views/index.phtml', $envelope['panel'][0]['message']); } - - public function testTimeIsMeasuredBetweenBeforeAndAfterRender(): void - { - $collector = new ViewCollector(); - $eventsManager = new Manager(); - $collector->subscribe($eventsManager); - - $eventsManager->fire('view:beforeRenderView', $this, '/app/views/index.phtml'); - $eventsManager->fire('view:afterRenderView', $this); - - $property = new ReflectionProperty(ViewCollector::class, 'rendered'); - $property->setAccessible(true); - - /** @var array $rendered */ - $rendered = $property->getValue($collector); - $time = $rendered[0]['time']; - - /** - * The delta between two consecutive event fires is a handful of - * nanoseconds. If the subtraction became an addition, `time` would - * carry the full monotonic clock value (billions of nanoseconds). - */ - $this->assertGreaterThanOrEqual(0, $time); - $this->assertLessThan(1000000000, $time); - } - - public function testUnmatchedAfterRenderIsIgnored(): void - { - $collector = new ViewCollector(); - $eventsManager = new Manager(); - $collector->subscribe($eventsManager); - - $eventsManager->fire('view:afterRenderView', $this); - - $this->assertSame(0, $collector->collect()['badge']); - } } diff --git a/tests/Unit/DebugBar/CoreTest.php b/tests/Unit/DebugBar/CoreTest.php index 1ba0419..5cb8f3e 100644 --- a/tests/Unit/DebugBar/CoreTest.php +++ b/tests/Unit/DebugBar/CoreTest.php @@ -27,7 +27,9 @@ use Phalcon\Http\RequestInterface; use Phalcon\Http\Response; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use Phalcon\Tests\Support\DebugBar\Fixtures\SubscribingCollector; use PHPUnit\Framework\Attributes\BackupGlobals; +use RuntimeException; use function array_keys; use function is_array; @@ -104,6 +106,17 @@ public function testBootInjectsWithNoFrameworkObjects(): void $this->assertInstanceOf(DebugBar::class, Debug::getBar()); } + public function testBootLeavesTheMvcEventsToTheMvcBridge(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $manager = new Manager(); + + $this->core()->boot($manager); + $manager->fire('dispatch:beforeException', $this, new RuntimeException('boom')); + + $this->assertSame(0, $this->bootedBar()->collect()['data']['exceptions']['badge']); + } + public function testBootReturnsAListenerForAnyResponseEvent(): void { $_ENV[self::ENV_VAR] = 'dev'; @@ -132,6 +145,23 @@ public function testBootSubscribesCollectorsToTheEventsManager(): void $this->assertSame(1, $this->bootedBar()->collect()['data']['cache']['badge']); } + public function testCollectorAddedAfterBootGetsItsEventsAndReachesTheBar(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $manager = new Manager(); + $core = $this->core(); + + $core->boot($manager); + $this->bootedBar()->addCollector(new SubscribingCollector()); + $manager->fire(SubscribingCollector::EVENT, $this, 'happened'); + + $response = $this->response(); + $core->handle($response); + + $this->assertSame(1, $this->bootedBar()->getData()['data'][SubscribingCollector::NAME]['badge']); + $this->assertStringContainsString(SubscribingCollector::NAME, $response->getContent()); + } + public function testConfigSourceReachesTheCollectorRedacted(): void { $_ENV[self::ENV_VAR] = 'dev'; diff --git a/tests/Unit/DebugBar/DebugBarTest.php b/tests/Unit/DebugBar/DebugBarTest.php index 4d55bf0..245a4fd 100644 --- a/tests/Unit/DebugBar/DebugBarTest.php +++ b/tests/Unit/DebugBar/DebugBarTest.php @@ -15,9 +15,12 @@ use Phalcon\DebugBar\DebugBar; use Phalcon\DebugBar\Exceptions\Exception; +use Phalcon\Events\Manager; +use Phalcon\Events\ManagerInterface; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; use Phalcon\Tests\Support\DebugBar\Fixtures\GridCollector; use Phalcon\Tests\Support\DebugBar\Fixtures\ListCollector; +use Phalcon\Tests\Support\DebugBar\Fixtures\SubscribingCollector; final class DebugBarTest extends AbstractUnitTestCase { @@ -45,6 +48,29 @@ public function testAddCollectorIsLastWriteWins(): void $this->assertCount(1, $bar->getCollectors()); } + public function testAddCollectorSubscribesASubscriber(): void + { + $manager = new Manager(); + $collector = new SubscribingCollector(); + + (new DebugBar($manager))->addCollector($collector); + $manager->fire(SubscribingCollector::EVENT, $this, 'happened'); + + $this->assertSame([['message' => 'happened']], $collector->collect()['panel']); + } + + public function testAddCollectorWithNoEventsManagerDoesNotSubscribe(): void + { + $collector = new SubscribingCollector(); + + (new DebugBar())->addCollector($collector); + + $manager = new Manager(); + $manager->fire(SubscribingCollector::EVENT, $this, 'happened'); + + $this->assertSame([], $collector->collect()['panel']); + } + public function testCollectAggregatesEveryCollectorAndCaches(): void { $bar = new DebugBar(); @@ -65,6 +91,14 @@ public function testCollectAggregatesEveryCollectorAndCaches(): void $this->assertSame($result, $bar->getData()); } + public function testCollectorThatIsNotASubscriberAttachesNothing(): void + { + $manager = $this->createMock(ManagerInterface::class); + $manager->expects($this->never())->method('attach'); + + (new DebugBar($manager))->addCollector(new GridCollector()); + } + public function testGetCollectorThrowsForUnknown(): void { $bar = new DebugBar(); diff --git a/tests/Unit/DebugBar/Mvc/BridgeTest.php b/tests/Unit/DebugBar/Mvc/BridgeTest.php new file mode 100644 index 0000000..89c09d5 --- /dev/null +++ b/tests/Unit/DebugBar/Mvc/BridgeTest.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Unit\DebugBar\Mvc; + +use Phalcon\DebugBar\Collector\ExceptionsCollector; +use Phalcon\DebugBar\Collector\RouteCollector; +use Phalcon\DebugBar\Contracts\Collector; +use Phalcon\DebugBar\DebugBar; +use Phalcon\DebugBar\Mvc\Bridge; +use Phalcon\Events\Manager; +use Phalcon\Events\ManagerInterface; +use Phalcon\Mvc\RouterInterface; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use RuntimeException; +use stdClass; + +final class BridgeTest extends AbstractUnitTestCase +{ + public function testBarWithNoCollectorsAttachesNothing(): void + { + $manager = $this->createMock(ManagerInterface::class); + $manager->expects($this->never())->method('attach'); + + (new Bridge(new DebugBar()))->subscribe($manager); + } + + public function testDispatchExceptionsAreAutoRecorded(): void + { + $collector = new ExceptionsCollector(); + $eventsManager = $this->subscribed($collector); + + $eventsManager->fire('dispatch:beforeException', $this, new RuntimeException('dispatched')); + + $envelope = $collector->collect(); + + $this->assertSame(1, $envelope['badge']); + $this->assertStringContainsString('dispatched', $envelope['panel'][0]['message']); + } + + public function testNonRouterSourceIsIgnored(): void + { + $collector = new RouteCollector(); + $eventsManager = $this->subscribed($collector); + + $eventsManager->fire('router:matchedRoute', new stdClass(), null); + + $this->assertSame([], $collector->collect()['panel']); + } + + public function testNonThrowableEventPayloadIsIgnored(): void + { + $collector = new ExceptionsCollector(); + $eventsManager = $this->subscribed($collector); + + $eventsManager->fire('dispatch:beforeException', $this, 'not-a-throwable'); + + $this->assertSame(0, $collector->collect()['badge']); + } + + public function testRouteIsSnapshotFromTheRouter(): void + { + $collector = new RouteCollector(); + $eventsManager = $this->subscribed($collector); + + $router = $this->createMock(RouterInterface::class); + $router->method('getModuleName')->willReturn('frontend'); + $router->method('getNamespaceName')->willReturn('App\Controllers'); + $router->method('getControllerName')->willReturn('session'); + $router->method('getActionName')->willReturn('login'); + $router->method('getParams')->willReturn(['id' => 5]); + + $eventsManager->fire('router:matchedRoute', $router, null); + + $panel = $collector->collect()['panel']; + + $this->assertSame('frontend', $panel['Module']); + $this->assertArrayHasKey('Namespace', $panel); + $this->assertSame('App\Controllers', $panel['Namespace']); + $this->assertSame('session', $panel['Controller']); + $this->assertSame('login', $panel['Action']); + $this->assertSame('{"id":5}', $panel['Params']); + } + + public function testRouterIsReadAtCollectTime(): void + { + $collector = new RouteCollector(); + $eventsManager = $this->subscribed($collector); + + $action = 'before'; + $router = $this->createMock(RouterInterface::class); + $router->method('getActionName')->willReturnCallback( + function () use (&$action): string { + return $action; + } + ); + + $eventsManager->fire('router:matchedRoute', $router, null); + $action = 'after'; + + $this->assertSame('after', $collector->collect()['panel']['Action']); + } + + private function subscribed(Collector $collector): Manager + { + $eventsManager = new Manager(); + + (new Bridge((new DebugBar())->addCollector($collector)))->subscribe($eventsManager); + + return $eventsManager; + } +} diff --git a/tests/Unit/DebugBar/ProviderTest.php b/tests/Unit/DebugBar/ProviderTest.php index 51f7995..7a6aae8 100644 --- a/tests/Unit/DebugBar/ProviderTest.php +++ b/tests/Unit/DebugBar/ProviderTest.php @@ -32,11 +32,14 @@ use Phalcon\Mvc\Application; use Phalcon\Mvc\Dispatcher; use Phalcon\Mvc\Router; +use Phalcon\Mvc\RouterInterface; use Phalcon\Mvc\Url; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use Phalcon\Tests\Support\DebugBar\Fixtures\SubscribingCollector; use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use RuntimeException; use stdClass; use function array_keys; @@ -231,6 +234,19 @@ public function testCustomBlockedListReplacesDefault(): void $this->assertTrue($provider->isAllowed()); } + public function testCustomCollectorGetsItsEvents(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $em = new Manager(); + $collector = new SubscribingCollector(); + + $this->provider($this->application($em))->boot(); + Debug::getBar()?->addCollector($collector); + $em->fire(SubscribingCollector::EVENT, $this, 'happened'); + + $this->assertSame([['message' => 'happened']], $collector->collect()['panel']); + } + public function testDefaultBootRegistersEveryCollector(): void { $_ENV[self::ENV_VAR] = 'dev'; @@ -628,6 +644,27 @@ public function testMultibyteEnvironmentIsLowercased(): void $this->assertFalse($provider->isAllowed()); } + public function testMvcEventsReachTheCollectors(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $em = new Manager(); + $app = $this->application($em); + + $this->provider($app)->boot(); + + $router = $this->createMock(RouterInterface::class); + $router->method('getActionName')->willReturn('login'); + $em->fire('router:matchedRoute', $router); + $em->fire('dispatch:beforeException', $app, new RuntimeException('boom')); + + $data = $this->bootedBar()->collect()['data']; + $route = $data['route']['panel']; + + $this->assertIsArray($route); + $this->assertSame('login', $route['Action']); + $this->assertSame(1, $data['exceptions']['badge']); + } + public function testNonceIsStampedOnInjectedTags(): void { $_ENV[self::ENV_VAR] = 'dev'; diff --git a/tests/Unit/DebugBar/ServiceBridgeTest.php b/tests/Unit/DebugBar/ServiceBridgeTest.php new file mode 100644 index 0000000..88c2725 --- /dev/null +++ b/tests/Unit/DebugBar/ServiceBridgeTest.php @@ -0,0 +1,226 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Unit\DebugBar; + +use Phalcon\Db\Adapter\AbstractAdapter; +use Phalcon\DebugBar\Collector\CacheCollector; +use Phalcon\DebugBar\Collector\DatabaseCollector; +use Phalcon\DebugBar\Collector\ViewCollector; +use Phalcon\DebugBar\Contracts\Collector; +use Phalcon\DebugBar\DebugBar; +use Phalcon\DebugBar\ServiceBridge; +use Phalcon\Events\Manager; +use Phalcon\Events\ManagerInterface; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use ReflectionProperty; +use stdClass; + +use function array_column; + +final class ServiceBridgeTest extends AbstractUnitTestCase +{ + public function testBarWithNoCollectorsAttachesNothing(): void + { + $manager = $this->createMock(ManagerInterface::class); + $manager->expects($this->never())->method('attach'); + + (new ServiceBridge(new DebugBar()))->subscribe($manager); + } + + public function testBindingsAreAppendedToTheQuery(): void + { + $collector = new DatabaseCollector(); + $eventsManager = $this->subscribed($collector); + + $connection = $this->createMock(AbstractAdapter::class); + $connection->method('getSQLStatement')->willReturn('SELECT * FROM users WHERE id = :APL0'); + $connection->method('getSQLVariables')->willReturn(['APL0' => 5]); + + $eventsManager->fire('db:beforeQuery', $connection); + $eventsManager->fire('db:afterQuery', $connection); + + $message = $collector->collect()['panel'][0]['message']; + + $this->assertSame( + 'SELECT * FROM users WHERE id = :APL0 {"APL0":5}', + $message + ); + } + + public function testCacheKeyThatIsNotAStringIsRecordedEmpty(): void + { + $collector = new CacheCollector(); + $eventsManager = $this->subscribed($collector); + + $eventsManager->fire('cache:afterGet', $this, ['key']); + + $this->assertSame('', $collector->collect()['panel'][0]['message']); + } + + public function testCacheOperationsAreRecordedFromEvents(): void + { + $collector = new CacheCollector(); + $eventsManager = $this->subscribed($collector); + + $eventsManager->fire('cache:afterGet', $this, 'user_42'); + $eventsManager->fire('cache:afterSet', $this, 'user_42'); + + $envelope = $collector->collect(); + + $this->assertSame(2, $envelope['badge']); + $this->assertSame('Get', $envelope['panel'][0]['label']); + $this->assertSame('user_42', $envelope['panel'][0]['message']); + $this->assertSame('Set', $envelope['panel'][1]['label']); + } + + public function testCacheOperationsHaveTheirLabels(): void + { + $collector = new CacheCollector(); + $eventsManager = $this->subscribed($collector); + + foreach (['Get', 'Set', 'Has', 'Delete'] as $operation) { + $eventsManager->fire('cache:after' . $operation, $this, 'key'); + } + + $this->assertSame( + ['Get', 'Set', 'Has', 'Delete'], + array_column($collector->collect()['panel'], 'label') + ); + } + + public function testDurationIsMeasuredBetweenBeforeAndAfterQuery(): void + { + $collector = new DatabaseCollector(); + $eventsManager = $this->subscribed($collector); + + $connection = $this->createMock(AbstractAdapter::class); + $connection->method('getSQLStatement')->willReturn('SELECT 1'); + $connection->method('getSQLVariables')->willReturn([]); + + $eventsManager->fire('db:beforeQuery', $connection); + $eventsManager->fire('db:afterQuery', $connection); + + $property = new ReflectionProperty(DatabaseCollector::class, 'queries'); + $property->setAccessible(true); + + /** @var array $queries */ + $queries = $property->getValue($collector); + $time = $queries[0]['time']; + + /** + * The delta between two consecutive event fires is a handful of + * nanoseconds. If `db:beforeQuery` never seeded `started`, or the + * subtraction became an addition, `time` would carry the full monotonic + * clock value (billions of nanoseconds) instead. + */ + $this->assertGreaterThanOrEqual(0, $time); + $this->assertLessThan(1000000000, $time); + } + + public function testNonAdapterConnectionIsIgnored(): void + { + $collector = new DatabaseCollector(); + $eventsManager = $this->subscribed($collector); + + $eventsManager->fire('db:afterQuery', new stdClass()); + + $this->assertSame(0, $collector->collect()['badge']); + } + + public function testQueriesAreRecordedFromEvents(): void + { + $collector = new DatabaseCollector(); + $eventsManager = $this->subscribed($collector); + + $connection = $this->createMock(AbstractAdapter::class); + $connection->method('getSQLStatement')->willReturn('SELECT 1'); + $connection->method('getSQLVariables')->willReturn([]); + + $eventsManager->fire('db:beforeQuery', $connection); + $eventsManager->fire('db:afterQuery', $connection); + + $envelope = $collector->collect(); + + $this->assertSame(1, $envelope['badge']); + $this->assertSame('SELECT 1', $envelope['panel'][0]['message']); + } + + public function testRenderedViewsAreRecordedFromEvents(): void + { + $collector = new ViewCollector(); + $eventsManager = $this->subscribed($collector); + + $eventsManager->fire('view:beforeRenderView', $this, '/app/views/index.phtml'); + $eventsManager->fire('view:afterRenderView', $this); + + $envelope = $collector->collect(); + + $this->assertSame(1, $envelope['badge']); + $this->assertSame('/app/views/index.phtml', $envelope['panel'][0]['message']); + } + + public function testTimeIsMeasuredBetweenBeforeAndAfterRender(): void + { + $collector = new ViewCollector(); + $eventsManager = $this->subscribed($collector); + + $eventsManager->fire('view:beforeRenderView', $this, '/app/views/index.phtml'); + $eventsManager->fire('view:afterRenderView', $this); + + $property = new ReflectionProperty(ViewCollector::class, 'rendered'); + $property->setAccessible(true); + + /** @var array $rendered */ + $rendered = $property->getValue($collector); + $time = $rendered[0]['time']; + + /** + * The delta between two consecutive event fires is a handful of + * nanoseconds. If the subtraction became an addition, `time` would + * carry the full monotonic clock value (billions of nanoseconds). + */ + $this->assertGreaterThanOrEqual(0, $time); + $this->assertLessThan(1000000000, $time); + } + + public function testUnmatchedAfterRenderIsIgnored(): void + { + $collector = new ViewCollector(); + $eventsManager = $this->subscribed($collector); + + $eventsManager->fire('view:afterRenderView', $this); + + $this->assertSame(0, $collector->collect()['badge']); + } + + public function testViewPathThatIsNotAStringIsRecordedEmpty(): void + { + $collector = new ViewCollector(); + $eventsManager = $this->subscribed($collector); + + $eventsManager->fire('view:beforeRenderView', $this, null); + $eventsManager->fire('view:afterRenderView', $this); + + $this->assertSame('', $collector->collect()['panel'][0]['message']); + } + + private function subscribed(Collector $collector): Manager + { + $eventsManager = new Manager(); + + (new ServiceBridge((new DebugBar())->addCollector($collector)))->subscribe($eventsManager); + + return $eventsManager; + } +} diff --git a/tests/support/DebugBar/Fixtures/SubscribingCollector.php b/tests/support/DebugBar/Fixtures/SubscribingCollector.php new file mode 100644 index 0000000..b6db6df --- /dev/null +++ b/tests/support/DebugBar/Fixtures/SubscribingCollector.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Support\DebugBar\Fixtures; + +use Phalcon\DebugBar\Collector\AbstractCollector; +use Phalcon\DebugBar\Contracts\Subscriber; +use Phalcon\Events\EventInterface; +use Phalcon\Events\ManagerInterface; + +use function count; +use function is_string; + +/** + * A custom collector that attaches its own listener, as an application does. + */ +final class SubscribingCollector extends AbstractCollector implements Subscriber +{ + public const EVENT = 'fixture:happened'; + + public const NAME = 'subscribing_fixture'; + + protected string $panel = 'list'; + + /** + * @var list + */ + private array $rows = []; + + /** + * @return array{panel: list, badge: int} + */ + public function collect(): array + { + return [ + 'panel' => $this->rows, + 'badge' => count($this->rows), + ]; + } + + public function subscribe(ManagerInterface $eventsManager): void + { + $eventsManager->attach( + self::EVENT, + function (EventInterface $event, mixed $source, mixed $data): void { + $this->rows[] = ['message' => is_string($data) ? $data : '']; + } + ); + } +}