Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
n/a
0 / 0
n/a
0 / 0
CRAP
n/a
0 / 0
JoliNotifExecutionFinishedSubscriber
n/a
0 / 0
n/a
0 / 0
21
n/a
0 / 0
 __construct
n/a
0 / 0
n/a
0 / 0
1
 notify
n/a
0 / 0
n/a
0 / 0
4
 getTelemetryBody
n/a
0 / 0
n/a
0 / 0
2
 getTitle
n/a
0 / 0
n/a
0 / 0
2
 getBody
n/a
0 / 0
n/a
0 / 0
2
 hasIssues
n/a
0 / 0
n/a
0 / 0
1
 getSuccessBody
n/a
0 / 0
n/a
0 / 0
2
 getFailedBody
n/a
0 / 0
n/a
0 / 0
6
 getPassedTests
n/a
0 / 0
n/a
0 / 0
1
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\PhpUnit\Event\TestSuite;
21
22use FastForward\DevTools\Path\DevToolsPathResolver;
23use FastForward\DevTools\PhpUnit\Event\EventTracer;
24use Joli\JoliNotif\DefaultNotifier;
25use Joli\JoliNotif\Notification;
26use Joli\JoliNotif\NotifierInterface;
27use PHPUnit\Event\Test\Errored;
28use PHPUnit\Event\Test\Failed;
29use PHPUnit\Event\Test\Prepared;
30use PHPUnit\Event\TestRunner\ExecutionFinished;
31use PHPUnit\Event\TestRunner\ExecutionFinishedSubscriber;
32use PHPUnit\Event\TestSuite\Finished;
33use PHPUnit\Event\TestSuite\Started;
34use Symfony\Component\Console\Helper\Helper;
35
36/**
37 * Sends a desktop notification when the PHPUnit execution finishes.
38 *
39 * This subscriber MUST summarize the current PHPUnit run using the counters
40 * collected by the shared event tracer and SHALL dispatch a concise desktop
41 * notification through the configured notifier implementation.
42 *
43 * The generated notification MUST preserve the effective behavior currently
44 * expected by the application:
45 * - successful runs SHALL report only the total number of executed tests,
46 *   followed by expanded telemetry details;
47 * - unsuccessful runs SHALL report the number of passed tests together with
48 *   the relevant failure and error counters, followed by expanded telemetry
49 *   details.
50 *
51 * When process forking support is available, notification delivery SHOULD be
52 * delegated to a child process so the main PHPUnit process MAY continue
53 * shutting down without waiting for the desktop notification transport to
54 * complete.
55 *
56 * @codeCoverageIgnore
57 */
58  class JoliNotifExecutionFinishedSubscriber implements ExecutionFinishedSubscriber
59{
60    /**
61     * Creates a new execution-finished subscriber instance.
62     *
63     * The provided tracer MUST contain the event history collected during the
64     * current PHPUnit run so this subscriber can derive an accurate summary.
65     * When no notifier is explicitly provided, the default JoliNotif notifier
66     * SHALL be used.
67     *
68     * @param EventTracer $tracer the event tracer used to inspect recorded
69     *                            PHPUnit events and derive notification data
70     * @param NotifierInterface $notifier the notifier responsible for
71     *                                    dispatching the desktop notification
72     */
73    public function __construct(
74        private EventTracer $tracer,
75        private NotifierInterface $notifier = new DefaultNotifier(),
76    ) {}
77
78    /**
79     * Handles the PHPUnit execution finished event.
80     *
81     * This method MUST build the final notification payload using the computed
82     * title, execution summary, and telemetry details.
83     * When process forking is available, the parent process SHALL return
84     * immediately after a successful fork, and the child process SHALL send
85     * the notification and terminate explicitly. When forking support is not
86     * available, or the fork attempt fails, the notification MUST still be
87     * delivered synchronously so functional behavior remains unchanged.
88     *
89     * @param ExecutionFinished $event the emitted PHPUnit execution finished
90     *                                 event
91     *
92     * @return void
93     */
94    public function notify(ExecutionFinished $event): void
95    {
96        $notification = (new Notification())
97            ->setTitle($this->getTitle())
98            ->setBody($this->getBody() . $this->getTelemetryBody($event))
99            ->setIcon(DevToolsPathResolver::getResourcesPath('phpunit.avif'));
100
101        $pid = \function_exists('pcntl_fork') ? pcntl_fork() : -1;
102
103        if ($pid > 0) {
104            return;
105        }
106
107        $this->notifier->send($notification);
108
109        if (0 === $pid) {
110            exit(0);
111        }
112    }
113
114    /**
115     * Builds the telemetry block appended to the notification body.
116     *
117     * PHPUnit telemetry MUST be expanded into explicit, human-readable metrics
118     * instead of being displayed as a compact raw string. This method SHALL
119     * expose the most relevant execution data in a readable multi-line block,
120     * including runtime, memory consumption, suite progression, prepared test
121     * count, and the overall assertion status.
122     *
123     * @param ExecutionFinished $event the PHPUnit execution finished event
124     *
125     * @return string the formatted telemetry block, including its leading line
126     *                breaks
127     */
128    private function getTelemetryBody(ExecutionFinished $event): string
129    {
130        $telemetryInfo = $event->telemetryInfo();
131
132        $lines = [
133            'Telemetry',
134            \sprintf('- Runtime: %s', Helper::formatTime($telemetryInfo->durationSinceStart()->seconds())),
135            \sprintf('- Peak memory: %s', Helper::formatMemory($telemetryInfo->peakMemoryUsage()->bytes())),
136            \sprintf('- Current memory: %s', Helper::formatMemory($telemetryInfo->memoryUsage()->bytes())),
137            \sprintf(
138                '- Test suites: %d/%d finished',
139                $this->tracer->count(Finished::class),
140                $this->tracer->count(Started::class)
141            ),
142            \sprintf('- Tests prepared: %d', $this->tracer->count(Prepared::class)),
143            \sprintf('- Assertions mode: %s', $this->hasIssues() ? 'completed with issues' : 'all checks passed'),
144        ];
145
146        return "\n\n" . implode("\n", $lines);
147    }
148
149    /**
150     * Builds the notification title for the current execution result.
151     *
152     * Successful executions MUST produce a success-oriented title. Executions
153     * containing at least one failure or error SHALL produce a failure-oriented
154     * title.
155     *
156     * @return string the formatted notification title
157     */
158    private function getTitle(): string
159    {
160        if (! $this->hasIssues()) {
161            return '✅ Test Suite Passed';
162        }
163
164        return '❌ Test Suite Failed';
165    }
166
167    /**
168     * Builds the main notification body for the current execution result.
169     *
170     * Successful executions MUST produce a minimal body containing only the
171     * total number of executed tests. Executions with failures or errors SHALL
172     * produce a body containing the number of passed tests together with the
173     * relevant failure and error counters.
174     *
175     * @return string the formatted notification body
176     */
177    private function getBody(): string
178    {
179        if (! $this->hasIssues()) {
180            return $this->getSuccessBody();
181        }
182
183        return $this->getFailedBody();
184    }
185
186    /**
187     * Determines whether the current test run contains at least one failure or error.
188     *
189     * @return bool true when the execution contains failures or errors;
190     *              otherwise false
191     */
192    private function hasIssues(): bool
193    {
194        return 0 < ($this->tracer->count(Errored::class) + $this->tracer->count(Failed::class));
195    }
196
197    /**
198     * Builds the notification body for a fully successful test run.
199     *
200     * The success body SHOULD remain intentionally brief because additional
201     * diagnostic detail is unnecessary when all tests pass.
202     *
203     * @return string the formatted success body
204     */
205    private function getSuccessBody(): string
206    {
207        return \sprintf(
208            '%d test%s passed',
209            $this->tracer->count(Prepared::class),
210            1 === $this->tracer->count(Prepared::class) ? '' : 's'
211        );
212    }
213
214    /**
215     * Builds the notification body for a test run containing failures or errors.
216     *
217     * The failed body MUST include the total number of passed tests and SHALL
218     * include failure and error counters only when those counters are greater
219     * than zero.
220     *
221     * @return string the formatted failure body
222     */
223    private function getFailedBody(): string
224    {
225        $body = [
226            \sprintf(
227                '%d of %d test%s passed',
228                $this->getPassedTests(),
229                $this->tracer->count(Prepared::class),
230                1 === $this->tracer->count(Prepared::class) ? '' : 's',
231            ),
232        ];
233
234        if (0 < $this->tracer->count(Failed::class)) {
235            $body[] = \sprintf(
236                '%d failure%s',
237                $this->tracer->count(Failed::class),
238                1 === $this->tracer->count(Failed::class) ? '' : 's'
239            );
240        }
241
242        if (0 < $this->tracer->count(Errored::class)) {
243            $body[] = \sprintf(
244                '%d error%s',
245                $this->tracer->count(Errored::class),
246                1 === $this->tracer->count(Errored::class) ? '' : 's'
247            );
248        }
249
250        return implode("\n", $body);
251    }
252
253    /**
254     * Calculates the number of tests that completed successfully.
255     *
256     * The returned value MUST NEVER be negative, even if the collected event
257     * counters become inconsistent for any reason.
258     *
259     * @return int the number of successfully completed tests
260     */
261    private function getPassedTests(): int
262    {
263        return max(
264            0,
265            $this->tracer->count(Prepared::class) - ($this->tracer->count(Errored::class) + $this->tracer->count(
266                Failed::class
267            ))
268        );
269    }
270}