diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow_debug.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow_debug.php index c37373e0f605a..4909b7d6921ef 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 05bc799536bb6..377b74f609f21 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 c6b9886f94f34..4f697a7a49b6e 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 69780121b35c3..656594dff6871 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 0000000000000..21b4fe6ecfe54 --- /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 3a95fdd268c54..689219800eea1 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" },