From adcc652ed8c05dfdf33a8ef23b71e56b3653f175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 1 Aug 2023 15:38:59 +0200 Subject: [PATCH] [Workflow] List place or transition listeners in profiler --- .../Resources/config/workflow_debug.php | 2 + .../views/Collector/workflow.html.twig | 209 ++++++++++++++++++ .../Resources/views/Icon/workflow.svg | 9 +- .../DataCollector/WorkflowDataCollector.php | 125 +++++++++++ .../WorkflowDataCollectorTest.php | 92 ++++++++ src/Symfony/Component/Workflow/composer.json | 2 + 6 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Component/Workflow/Tests/DataCollector/WorkflowDataCollectorTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow_debug.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow_debug.php index c37373e0f605..4909b7d6921e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow_debug.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow_debug.php @@ -22,6 +22,8 @@ ]) ->args([ tagged_iterator('workflow', 'name'), + service('event_dispatcher'), + service('debug.file_link_formatter'), ]) ; }; diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig index 05bc799536bb..377b74f609f2 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig @@ -1,5 +1,102 @@ {% extends '@WebProfiler/Profiler/layout.html.twig' %} +{% block stylesheets %} + {{ parent() }} + +{% endblock %} + {% block toolbar %} {% if collector.callsCount > 0 %} {% set icon %} @@ -40,6 +137,93 @@ flowchart: { useMaxWidth: false }, securityLevel: 'loose', }); + + {% for name, data in collector.workflows %} + window.showNodeDetails{{ collector.hash(name) }} = function (node) { + const map = {{ data.listeners|json_encode|raw }}; + showNodeDetails(node, map); + }; + {% endfor %} + + const showNodeDetails = function (node, map) { + const dialog = document.getElementById('detailsDialog'); + + dialog.querySelector('tbody').innerHTML = ''; + for (const [eventName, listeners] of Object.entries(map[node])) { + listeners.forEach(listener => { + const row = document.createElement('tr'); + + const eventNameCode = document.createElement('code'); + eventNameCode.textContent = eventName; + + const eventNameCell = document.createElement('td'); + eventNameCell.appendChild(eventNameCode); + row.appendChild(eventNameCell); + + const listenerDetailsCell = document.createElement('td'); + row.appendChild(listenerDetailsCell); + + let listenerDetails; + const listenerDetailsCode = document.createElement('code'); + listenerDetailsCode.textContent = listener.title; + if (listener.file) { + const link = document.createElement('a'); + link.href = listener.file; + link.appendChild(listenerDetailsCode); + listenerDetails = link; + } else { + listenerDetails = listenerDetailsCode; + } + listenerDetailsCell.appendChild(listenerDetails); + + if (typeof listener.guardExpressions === 'object') { + listenerDetailsCell.appendChild(document.createElement('br')); + + const guardExpressionsWrapper = document.createElement('span'); + guardExpressionsWrapper.appendChild(document.createTextNode('guard expressions: ')); + + listener.guardExpressions.forEach((expression, index) => { + if (index > 0) { + guardExpressionsWrapper.appendChild(document.createTextNode(', ')); + } + + const expressionCode = document.createElement('code'); + expressionCode.textContent = expression; + guardExpressionsWrapper.appendChild(expressionCode); + }); + + listenerDetailsCell.appendChild(guardExpressionsWrapper); + } + + dialog.querySelector('tbody').appendChild(row); + }); + }; + + if (dialog.dataset.processed) { + dialog.showModal(); + return; + } + + dialog.addEventListener('click', (e) => { + const rect = dialog.getBoundingClientRect(); + + const inDialog = + rect.top <= e.clientY && + e.clientY <= rect.top + rect.height && + rect.left <= e.clientX && + e.clientX <= rect.left + rect.width; + + !inDialog && dialog.close(); + }); + + dialog.querySelectorAll('.cancel').forEach(elt => { + elt.addEventListener('click', () => dialog.close()); + }); + + dialog.showModal(); + + dialog.dataset.processed = true; + }; // We do not load all mermaid diagrams at once, but only when the tab is opened // This is because mermaid diagrams are in a tab, and cannot be renderer with a // "good size" if they are not visible @@ -71,6 +255,9 @@

Definition

                             {{ data.dump|raw }}
+                            {% for nodeId, events in data.listeners %}
+                                click {{ nodeId }} showNodeDetails{{ collector.hash(name) }}
+                            {% endfor %}
                         

Calls

@@ -128,4 +315,26 @@ {% endfor %} {% endif %} + + +

+ Event listeners + × +

+ + + + + + + + + + +
eventlistener
+ + esc + + +
{% endblock %} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/workflow.svg b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/workflow.svg index c6b9886f94f3..4f697a7a49b6 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/workflow.svg +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Icon/workflow.svg @@ -1 +1,8 @@ - + + + + + + + + diff --git a/src/Symfony/Component/Workflow/DataCollector/WorkflowDataCollector.php b/src/Symfony/Component/Workflow/DataCollector/WorkflowDataCollector.php index 69780121b35c..656594dff687 100644 --- a/src/Symfony/Component/Workflow/DataCollector/WorkflowDataCollector.php +++ b/src/Symfony/Component/Workflow/DataCollector/WorkflowDataCollector.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Workflow\DataCollector; +use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; @@ -19,8 +21,12 @@ use Symfony\Component\VarDumper\Cloner\Stub; use Symfony\Component\Workflow\Debug\TraceableWorkflow; use Symfony\Component\Workflow\Dumper\MermaidDumper; +use Symfony\Component\Workflow\EventListener\GuardExpression; +use Symfony\Component\Workflow\EventListener\GuardListener; use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Transition; use Symfony\Component\Workflow\TransitionBlocker; +use Symfony\Component\Workflow\WorkflowInterface; /** * @author Grégoire Pineau @@ -29,6 +35,8 @@ final class WorkflowDataCollector extends DataCollector implements LateDataColle { public function __construct( private readonly iterable $workflows, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly FileLinkFormatter $fileLinkFormatter, ) { } @@ -50,6 +58,7 @@ public function lateCollect(): void $this->data['workflows'][$workflow->getName()] = [ 'dump' => $dumper->dump($workflow->getDefinition()), 'calls' => $calls, + 'listeners' => $this->getEventListeners($workflow), ]; } } @@ -102,4 +111,120 @@ protected function getCasters(): array return $casters; } + + public function hash(string $string): string + { + return hash('xxh128', $string); + } + + private function getEventListeners(WorkflowInterface $workflow): array + { + $listeners = []; + $placeId = 0; + foreach ($workflow->getDefinition()->getPlaces() as $place) { + $eventNames = []; + $subEventNames = [ + 'leave', + 'enter', + 'entered', + ]; + foreach ($subEventNames as $subEventName) { + $eventNames[] = sprintf('workflow.%s', $subEventName); + $eventNames[] = sprintf('workflow.%s.%s', $workflow->getName(), $subEventName); + $eventNames[] = sprintf('workflow.%s.%s.%s', $workflow->getName(), $subEventName, $place); + } + foreach ($eventNames as $eventName) { + foreach ($this->eventDispatcher->getListeners($eventName) as $listener) { + $listeners["place{$placeId}"][$eventName][] = $this->summarizeListener($listener); + } + } + + ++$placeId; + } + + foreach ($workflow->getDefinition()->getTransitions() as $transitionId => $transition) { + $eventNames = []; + $subEventNames = [ + 'guard', + 'transition', + 'completed', + 'announce', + ]; + foreach ($subEventNames as $subEventName) { + $eventNames[] = sprintf('workflow.%s', $subEventName); + $eventNames[] = sprintf('workflow.%s.%s', $workflow->getName(), $subEventName); + $eventNames[] = sprintf('workflow.%s.%s.%s', $workflow->getName(), $subEventName, $transition->getName()); + } + foreach ($eventNames as $eventName) { + foreach ($this->eventDispatcher->getListeners($eventName) as $listener) { + $listeners["transition{$transitionId}"][$eventName][] = $this->summarizeListener($listener, $eventName, $transition); + } + } + } + + return $listeners; + } + + private function summarizeListener(callable $callable, string $eventName = null, Transition $transition = null): array + { + $extra = []; + + if ($callable instanceof \Closure) { + $r = new \ReflectionFunction($callable); + if (str_contains($r->name, '{closure}')) { + $title = (string) $r; + } elseif ($class = \PHP_VERSION_ID >= 80111 ? $r->getClosureCalledClass() : $r->getClosureScopeClass()) { + $title = $class->name.'::'.$r->name.'()'; + } else { + $title = $r->name; + } + } elseif (\is_string($callable)) { + $title = $callable.'()'; + $r = new \ReflectionFunction($callable); + } elseif (\is_object($callable) && method_exists($callable, '__invoke')) { + $r = new \ReflectionMethod($callable, '__invoke'); + $title = $callable::class.'::__invoke()'; + } elseif (\is_array($callable)) { + if ($callable[0] instanceof GuardListener) { + if (null === $eventName || null === $transition) { + throw new \LogicException('Missing event name or transition.'); + } + $extra['guardExpressions'] = $this->extractGuardExpressions($callable[0], $eventName, $transition); + } + $r = new \ReflectionMethod($callable[0], $callable[1]); + $title = (\is_string($callable[0]) ? $callable[0] : \get_class($callable[0])).'::'.$callable[1].'()'; + } else { + throw new \RuntimeException('Unknown callable type.'); + } + + $file = null; + if ($r->isUserDefined()) { + $file = $this->fileLinkFormatter->format($r->getFileName(), $r->getStartLine()); + } + + return [ + 'title' => $title, + 'file' => $file, + ...$extra, + ]; + } + + private function extractGuardExpressions(GuardListener $listener, string $eventName, Transition $transition): array + { + $configuration = (new \ReflectionProperty(GuardListener::class, 'configuration'))->getValue($listener); + + $expressions = []; + foreach ($configuration[$eventName] as $guard) { + if ($guard instanceof GuardExpression) { + if ($guard->getTransition() !== $transition) { + continue; + } + $expressions[] = $guard->getExpression(); + } else { + $expressions[] = $guard; + } + } + + return $expressions; + } } diff --git a/src/Symfony/Component/Workflow/Tests/DataCollector/WorkflowDataCollectorTest.php b/src/Symfony/Component/Workflow/Tests/DataCollector/WorkflowDataCollectorTest.php new file mode 100644 index 000000000000..21b4fe6ecfe5 --- /dev/null +++ b/src/Symfony/Component/Workflow/Tests/DataCollector/WorkflowDataCollectorTest.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Tests\DataCollector; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; +use Symfony\Component\Workflow\DataCollector\WorkflowDataCollector; +use Symfony\Component\Workflow\EventListener\ExpressionLanguage; +use Symfony\Component\Workflow\EventListener\GuardListener; +use Symfony\Component\Workflow\Tests\WorkflowBuilderTrait; +use Symfony\Component\Workflow\Workflow; + +class WorkflowDataCollectorTest extends TestCase +{ + use WorkflowBuilderTrait; + + public function test() + { + $workflow1 = new Workflow($this->createComplexWorkflowDefinition(), name: 'workflow1'); + $workflow2 = new Workflow($this->createSimpleWorkflowDefinition(), name: 'workflow2'); + $dispatcher = new EventDispatcher(); + $dispatcher->addListener('workflow.workflow2.leave.a', fn () => true); + $dispatcher->addListener('workflow.workflow2.leave.a', [self::class, 'noop']); + $dispatcher->addListener('workflow.workflow2.leave.a', [$this, 'noop']); + $dispatcher->addListener('workflow.workflow2.leave.a', $this->noop(...)); + $dispatcher->addListener('workflow.workflow2.leave.a', 'var_dump'); + $guardListener = new GuardListener( + ['workflow.workflow2.guard.t1' => ['my_expression']], + $this->createMock(ExpressionLanguage::class), + $this->createMock(TokenStorageInterface::class), + $this->createMock(AuthorizationCheckerInterface::class), + $this->createMock(AuthenticationTrustResolverInterface::class), + $this->createMock(RoleHierarchyInterface::class), + $this->createMock(ValidatorInterface::class) + ); + $dispatcher->addListener('workflow.workflow2.guard.t1', [$guardListener, 'onTransition']); + + $collector = new WorkflowDataCollector( + [$workflow1, $workflow2], + $dispatcher, + new FileLinkFormatter(), + ); + + $collector->lateCollect(); + + $data = $collector->getWorkflows(); + + $this->assertArrayHasKey('workflow1', $data); + $this->assertArrayHasKey('dump', $data['workflow1']); + $this->assertStringStartsWith("graph LR\n", $data['workflow1']['dump']); + $this->assertArrayHasKey('listeners', $data['workflow1']); + + $this->assertSame([], $data['workflow1']['listeners']); + $this->assertArrayHasKey('workflow2', $data); + $this->assertArrayHasKey('dump', $data['workflow2']); + $this->assertStringStartsWith("graph LR\n", $data['workflow1']['dump']); + $this->assertArrayHasKey('listeners', $data['workflow2']); + $listeners = $data['workflow2']['listeners']; + $this->assertArrayHasKey('place0', $listeners); + $this->assertArrayHasKey('workflow.workflow2.leave.a', $listeners['place0']); + $descriptions = $listeners['place0']['workflow.workflow2.leave.a']; + $this->assertCount(5, $descriptions); + $this->assertStringContainsString('Closure', $descriptions[0]['title']); + $this->assertSame('Symfony\Component\Workflow\Tests\DataCollector\WorkflowDataCollectorTest::noop()', $descriptions[1]['title']); + $this->assertSame('Symfony\Component\Workflow\Tests\DataCollector\WorkflowDataCollectorTest::noop()', $descriptions[2]['title']); + $this->assertSame('Symfony\Component\Workflow\Tests\DataCollector\WorkflowDataCollectorTest::noop()', $descriptions[3]['title']); + $this->assertSame('var_dump()', $descriptions[4]['title']); + $this->assertArrayHasKey('transition0', $listeners); + $this->assertArrayHasKey('workflow.workflow2.guard.t1', $listeners['transition0']); + $this->assertSame('Symfony\Component\Workflow\EventListener\GuardListener::onTransition()', $listeners['transition0']['workflow.workflow2.guard.t1'][0]['title']); + $this->assertSame(['my_expression'], $listeners['transition0']['workflow.workflow2.guard.t1'][0]['guardExpressions']); + } + + public static function noop() + { + } +} diff --git a/src/Symfony/Component/Workflow/composer.json b/src/Symfony/Component/Workflow/composer.json index 3a95fdd268c5..689219800eea 100644 --- a/src/Symfony/Component/Workflow/composer.json +++ b/src/Symfony/Component/Workflow/composer.json @@ -26,8 +26,10 @@ "require-dev": { "psr/log": "^1|^2|^3", "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^6.4|^7.0", "symfony/event-dispatcher": "^5.4|^6.0|^7.0", "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^6.4|^7.0", "symfony/security-core": "^5.4|^6.0|^7.0", "symfony/validator": "^5.4|^6.0|^7.0" }, pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy