Skip to content

Commit fdf3fcc

Browse files
committed
feat(report): introduce ReportEvent and related classes for report generation events
feat(junit): announce the written XML instead of printing its path feat(json): announce the file written in `--log-json` mode feat(teamcity): turn an announced report into the `testoReport` service message feat(terminal): state an announced report as a plain path A reporter dispatches its file instead of printing it, so it never has to know which renderer owns stdout. The early event carries the destination while the run tree is still open, which is the only moment an IDE can attach the TeamCity message to a node; the late one means the file can be read, which is what a human-facing line waits for. Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 390425b commit fdf3fcc

19 files changed

Lines changed: 479 additions & 3 deletions

File tree

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"php": ">=8.2",
3232
"ext-tokenizer": "*",
3333
"internal/destroy": "^1.0",
34-
"internal/path": "^1.2",
34+
"internal/path": "^1.3",
3535
"psr/container": "1 - 2",
3636
"psr/event-dispatcher": "^1.0",
3737
"psr/log": "^2.0 || ^3.0",

core/Core/Report/ReportInfo.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Core\Report;
6+
7+
/**
8+
* What a reporter knows about the report it writes: how to identify it, what to call it, and where it
9+
* lands.
10+
*
11+
* A reporter holds this from the moment its destination is settled — before the run, in the general case —
12+
* and the announcement events carry it as their payload.
13+
*
14+
* @psalm-immutable
15+
* @api
16+
*/
17+
final readonly class ReportInfo
18+
{
19+
/**
20+
* @param non-empty-string $format Machine-readable format id, e.g. `html` or `junit`. Names the dialect
21+
* a consumer would parse, not the file extension.
22+
* @param non-empty-string $name Human-readable label for the report.
23+
* @param \Stringable $path Where the report can be reached: an {@see \Internal\Path} for a file — the
24+
* entry file, `index.html` for a multi-file layout, never the directory — or a URL for a report
25+
* published to a service. Whatever form the reporter holds it in; a consumer that needs another
26+
* derives it from the string.
27+
*/
28+
public function __construct(
29+
public string $format,
30+
public string $name,
31+
public \Stringable $path,
32+
) {}
33+
}

core/Event/Report/ReportEvent.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Event\Report;
6+
7+
use Testo\Core\Report\ReportInfo;
8+
9+
/**
10+
* A report a run produces, announced to whoever renders the run's output.
11+
*
12+
* A reporter dispatches these instead of printing anything itself, so it never has to know which
13+
* renderer owns stdout. One event per report: a reporter that writes several formats, or one report per
14+
* suite, announces each of them.
15+
*
16+
* The payload is the reporter's card; the subclass fixes the kind of report and the moment —
17+
* {@see ReportFileGenerating} before a file is written, {@see ReportFileGenerated} after it. Subscribe
18+
* here to hear every announcement whatever its kind and moment, since the dispatcher matches an event's
19+
* parents, and to a concrete event when the moment is what you act on.
20+
*
21+
* @psalm-immutable
22+
* @api
23+
*/
24+
abstract readonly class ReportEvent
25+
{
26+
public function __construct(
27+
public ReportInfo $info,
28+
) {}
29+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Event\Report;
6+
7+
/**
8+
* Event triggered after a reporter has written a report file and closed it.
9+
*
10+
* The file exists and is complete, which makes this the event to act on for anything that reads the
11+
* report — a checksum, an upload, a follow-up artifact. A renderer states it as a plain line; the TeamCity
12+
* service message goes out earlier, on {@see ReportFileGenerating}.
13+
*
14+
* @psalm-immutable
15+
* @api
16+
*/
17+
final readonly class ReportFileGenerated extends ReportEvent {}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Event\Report;
6+
7+
/**
8+
* Event triggered when a reporter commits to writing a report file — as early as its destination is
9+
* known, which for a reporter built from a whole run is the moment the session starts.
10+
*
11+
* The path is where the file **will** be: opening it on this event finds nothing, or a stale report from
12+
* an earlier run, and a run that dies before writing leaves the promise unfulfilled. The event exists to
13+
* state the artifact while the run's output is still open — the TeamCity renderer turns this one, and only
14+
* this one, into the `##teamcity[testoReport …]` service message an IDE parses, and a message arriving
15+
* after the last `testSuiteFinished` has no node in the run tree to attach to.
16+
*
17+
* {@see ReportFileGenerated} states the same file once it is written — the event to act on for anything
18+
* that reads the report rather than points at it.
19+
*
20+
* @psalm-immutable
21+
* @api
22+
*/
23+
final readonly class ReportFileGenerating extends ReportEvent {}

core/Output/JUnit/JUnitPlugin.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@
66

77
use Internal\Container\Container;
88
use Internal\Path;
9+
use Psr\EventDispatcher\EventDispatcherInterface;
910
use Testo\Common\EventListenerCollector;
1011
use Testo\Common\PluginConfigurator;
1112
use Testo\Core\Context\CaseInfo;
1213
use Testo\Core\Context\TestInfo;
1314
use Testo\Core\Value\TestType;
1415
use Testo\Event\Framework\SessionFinished;
1516
use Testo\Event\Framework\SessionStarting;
17+
use Testo\Core\Report\ReportInfo;
18+
use Testo\Event\Report\ReportFileGenerated;
19+
use Testo\Event\Report\ReportFileGenerating;
1620
use Testo\Event\Test\TestBatchFinished;
1721
use Testo\Event\Test\TestBatchStarting;
1822
use Testo\Event\Test\TestDataSetFinished;
@@ -184,6 +188,18 @@ public function configure(Container $container): void
184188

185189
// Test Pipeline events (final event in the test lifecycle)
186190
$listeners->addListener(TestPipelineFinished::class, $this->onTestPipelineFinished(...));
191+
192+
// Registered after the listener that writes the file, so the late announcement follows the write.
193+
$info = new ReportInfo('junit', 'JUnit report', $this->resolvedPath);
194+
$dispatcher = $container->get(EventDispatcherInterface::class);
195+
$listeners->addListener(
196+
SessionStarting::class,
197+
static fn(): mixed => $dispatcher->dispatch(new ReportFileGenerating($info)),
198+
);
199+
$listeners->addListener(
200+
SessionFinished::class,
201+
static fn(): mixed => $dispatcher->dispatch(new ReportFileGenerated($info)),
202+
);
187203
}
188204

189205
private static function formatDatasetSuffix(string|int $datasetKey, ?int $providerIndex): string

core/Output/Json/JsonPlugin.php

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@
66

77
use Internal\Container\Container;
88
use Internal\Path;
9+
use Psr\EventDispatcher\EventDispatcherInterface;
910
use Testo\Common\EventListenerCollector;
1011
use Testo\Common\PluginConfigurator;
12+
use Testo\Core\Report\ReportInfo;
1113
use Testo\Event\Framework\SessionFinished;
14+
use Testo\Event\Framework\SessionStarting;
15+
use Testo\Event\Report\ReportFileGenerated;
16+
use Testo\Event\Report\ReportFileGenerating;
1217
use Testo\Output\Json\Internal\JsonReport;
1318

1419
/**
@@ -64,8 +69,24 @@ public function __construct(?string $outputPath = null, $stream = null)
6469
#[\Override]
6570
public function configure(Container $container): void
6671
{
67-
$container->get(EventListenerCollector::class)
68-
->addListener(SessionFinished::class, $this->onSessionFinished(...));
72+
$listeners = $container->get(EventListenerCollector::class);
73+
$listeners->addListener(SessionFinished::class, $this->onSessionFinished(...));
74+
75+
$path = $this->path;
76+
if ($path === null) {
77+
return;
78+
}
79+
80+
$info = new ReportInfo('json', 'JSON report', $path);
81+
$dispatcher = $container->get(EventDispatcherInterface::class);
82+
$listeners->addListener(
83+
SessionStarting::class,
84+
static fn(): mixed => $dispatcher->dispatch(new ReportFileGenerating($info)),
85+
);
86+
$listeners->addListener(
87+
SessionFinished::class,
88+
static fn(): mixed => $dispatcher->dispatch(new ReportFileGenerated($info)),
89+
);
6990
}
7091

7192
private function onSessionFinished(SessionFinished $event): void

core/Output/Teamcity/Teamcity/Formatter.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,32 @@ public static function compilationFinished(string $compiler): string
374374
return self::formatMessage('compilationFinished', ['compiler' => $compiler]);
375375
}
376376

377+
/**
378+
* Formats the announcement of a generated report.
379+
*
380+
* Non-standard: the TeamCity server ignores it, and an IDE plugin parses it explicitly to offer opening
381+
* the report. `path` is absolute inside the execution environment, which a container or a remote
382+
* interpreter makes meaningless on the consumer's machine — hence `relativePath` beside it, omitted
383+
* when the report lies outside the working directory.
384+
*
385+
* @param non-empty-string $format Format id, e.g. `html`.
386+
* @param non-empty-string $path Absolute path of the entry file in the execution environment.
387+
* @param non-empty-string|null $relativePath The same file relative to the working directory.
388+
* @param non-empty-string $name Human-readable label.
389+
* @return non-empty-string
390+
*/
391+
public static function testoReport(
392+
string $format,
393+
string $path,
394+
?string $relativePath,
395+
string $name,
396+
): string {
397+
$attributes = ['format' => $format, 'path' => $path, 'name' => $name];
398+
$relativePath === null or $attributes['relativePath'] = $relativePath;
399+
400+
return self::formatMessage('testoReport', $attributes);
401+
}
402+
377403
/**
378404
* Formats a TeamCity service message.
379405
*

core/Output/Teamcity/Teamcity/TeamcityLogger.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Testo\Output\Teamcity\Teamcity;
66

7+
use Internal\Path;
78
use Testo\Assert\State\Assertion\ComparisonFailure;
89
use Testo\Common\Environment;
910
use Testo\Common\Info;
@@ -17,6 +18,7 @@
1718
use Testo\Core\Context\TestResult;
1819
use Testo\Core\Log\Message;
1920
use Testo\Core\Value\Status;
21+
use Testo\Core\Report\ReportInfo;
2022
use Testo\Output\Rendering\StackTrace;
2123

2224
/**
@@ -339,6 +341,32 @@ public function logEmptyRun(): void
339341
$this->publish(Formatter::buildProblem('No tests were executed', 'testo.noTests'));
340342
}
341343

344+
public function logReport(ReportInfo $report): void
345+
{
346+
# A file report states a `Path` and gets both forms of it; any other location is announced as it
347+
# stands, since resolving a URL against the working directory would name nothing.
348+
$location = $report->path;
349+
350+
/**
351+
* @var \Stringable $absolute Absolute path or URL
352+
* @var Path|null $relative Relative path or null
353+
*/
354+
[$absolute, $relative] = match (true) {
355+
!$location instanceof Path => [$location, null],
356+
$location->isAbsolute() => [$location, $location->tryRelative(Path::create(\getcwd() ?: ''))],
357+
default => [$location->absolute(), $location],
358+
};
359+
360+
$relative === null or $relative->isWithin() or $relative = null;
361+
362+
$this->publish(Formatter::testoReport(
363+
format: $report->format,
364+
path: (string) $absolute,
365+
relativePath: $relative === null ? null : (string) $relative,
366+
name: $report->name,
367+
));
368+
}
369+
342370
/**
343371
* Handles single test result based on status.
344372
*

core/Output/Teamcity/TeamcityPlugin.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Testo\Event\Framework\SessionFinished;
1313
use Testo\Event\Framework\SessionStarting;
1414
use Testo\Event\Message\MessageReceived;
15+
use Testo\Event\Report\ReportFileGenerating;
1516
use Testo\Event\Test\TestBatchFinished;
1617
use Testo\Event\Test\TestBatchStarting;
1718
use Testo\Event\Test\TestDataSetFinished;
@@ -71,6 +72,9 @@ public function configure(Container $container): void
7172
$listeners->addListener(SessionStarting::class, $this->onSessionStarting(...));
7273
$listeners->addListener(SessionFinished::class, $this->onSessionFinished(...));
7374

75+
// Report files announced by any reporter plugin
76+
$listeners->addListener(ReportFileGenerating::class, $this->onReportFileGenerating(...));
77+
7478
// Messenger output — streamed in real time as stdout/stderr for the current test.
7579
$listeners->addListener(MessageReceived::class, $this->onMessageReceived(...));
7680

@@ -115,6 +119,11 @@ private function onSessionFinished(SessionFinished $event): void
115119
$event->result->summary->total() === 0 and $this->logger->logEmptyRun();
116120
}
117121

122+
private function onReportFileGenerating(ReportFileGenerating $event): void
123+
{
124+
$this->logger->logReport($event->info);
125+
}
126+
118127
private function onMessageReceived(MessageReceived $event): void
119128
{
120129
$identity = $event->identity;

0 commit comments

Comments
 (0)