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