diff --git a/plugin/codecov/src/Exception/CoverageReportNotWritten.php b/plugin/codecov/src/Exception/CoverageReportNotWritten.php
new file mode 100644
index 00000000..18b5b7df
--- /dev/null
+++ b/plugin/codecov/src/Exception/CoverageReportNotWritten.php
@@ -0,0 +1,38 @@
+cache->value->withSourceRoot($this->sourceRoot)
: $this->cache->value;
+ # One unwritable target must not cost the other reports, so the first failure is rethrown last.
+ $failure = null;
foreach ($this->reports as $report) {
- $report->generate($result);
+ try {
+ $report->generate($result);
+ } catch (CoverageReportNotWritten $e) {
+ $failure ??= $e;
+ continue;
+ }
+
$this->dispatcher?->dispatch(new ReportFileGenerated($report->info()));
}
+
+ $failure === null or throw $failure;
}
/**
diff --git a/plugin/codecov/src/Internal/ReportFile.php b/plugin/codecov/src/Internal/ReportFile.php
new file mode 100644
index 00000000..018138e1
--- /dev/null
+++ b/plugin/codecov/src/Internal/ReportFile.php
@@ -0,0 +1,57 @@
+endElement(); // coverage
$xml->endDocument();
- $dir = \dirname($this->outputPath);
- \is_dir($dir) or \mkdir($dir, 0o755, true);
- \file_put_contents($this->outputPath, $xml->outputMemory());
+ ReportFile::write($this->outputPath, $xml->outputMemory());
}
#[\Override]
diff --git a/plugin/codecov/src/Report/CoberturaReport.php b/plugin/codecov/src/Report/CoberturaReport.php
index 1adb7a56..94af2a89 100644
--- a/plugin/codecov/src/Report/CoberturaReport.php
+++ b/plugin/codecov/src/Report/CoberturaReport.php
@@ -6,6 +6,7 @@
use Internal\Path;
use Testo\Codecov\Internal\BranchCoverageAggregator;
+use Testo\Codecov\Internal\ReportFile;
use Testo\Codecov\Result\CoverageResult;
use Testo\Codecov\Result\FileCoverage;
use Testo\Codecov\Result\LineStatus;
@@ -82,9 +83,7 @@ public function generate(CoverageResult $result): void
$xml->endElement(); // coverage
$xml->endDocument();
- $dir = \dirname($this->outputPath);
- \is_dir($dir) or \mkdir($dir, 0o755, true);
- \file_put_contents($this->outputPath, $xml->outputMemory());
+ ReportFile::write($this->outputPath, $xml->outputMemory());
}
#[\Override]
diff --git a/plugin/codecov/src/Report/CoverageReport.php b/plugin/codecov/src/Report/CoverageReport.php
index f6804b88..32b6ab5d 100644
--- a/plugin/codecov/src/Report/CoverageReport.php
+++ b/plugin/codecov/src/Report/CoverageReport.php
@@ -4,6 +4,7 @@
namespace Testo\Codecov\Report;
+use Testo\Codecov\Exception\CoverageReportNotWritten;
use Testo\Codecov\Result\CoverageResult;
use Testo\Core\Report\ReportInfo;
@@ -14,6 +15,9 @@
*/
interface CoverageReport
{
+ /**
+ * @throws CoverageReportNotWritten When the target path cannot be written.
+ */
public function generate(CoverageResult $result): void;
/**
diff --git a/plugin/codecov/src/Report/PhpUnitXmlReport.php b/plugin/codecov/src/Report/PhpUnitXmlReport.php
index 93f00c05..dd2aca98 100644
--- a/plugin/codecov/src/Report/PhpUnitXmlReport.php
+++ b/plugin/codecov/src/Report/PhpUnitXmlReport.php
@@ -5,6 +5,7 @@
namespace Testo\Codecov\Report;
use Internal\Path;
+use Testo\Codecov\Internal\ReportFile;
use Testo\Codecov\Result\CoverageResult;
use Testo\Codecov\Result\FileCoverage;
use Testo\Codecov\Result\LineStatus;
@@ -46,7 +47,7 @@ public function generate(CoverageResult $result): void
{
$sourceRoot = (string) Path::create($result->sourceRoot ?? (string) \getcwd());
- \is_dir($this->outputDir) or \mkdir($this->outputDir, 0o755, true);
+ ReportFile::directory($this->outputDir);
$entries = [];
$totalStmts = 0;
@@ -189,7 +190,7 @@ private function writeIndex(array $entries, string $sourceRoot, int $totalStmts,
$xml->endElement(); // phpunit
$xml->endDocument();
- \file_put_contents($this->outputDir . '/index.xml', $xml->outputMemory());
+ ReportFile::write($this->outputDir . '/index.xml', $xml->outputMemory());
}
/**
@@ -200,9 +201,6 @@ private function writeFile(array $entry): void
$fileCoverage = $entry['fileCoverage'];
$outputPath = $this->outputDir . '/' . $entry['href'];
- $dir = \dirname($outputPath);
- \is_dir($dir) or \mkdir($dir, 0o755, true);
-
$xml = self::newXmlWriter();
$xml->startDocument('1.0', 'UTF-8');
@@ -242,6 +240,6 @@ private function writeFile(array $entry): void
$xml->endElement(); // phpunit
$xml->endDocument();
- \file_put_contents($outputPath, $xml->outputMemory());
+ ReportFile::write($outputPath, $xml->outputMemory());
}
}
diff --git a/plugin/codecov/tests/Unit/Internal/CoverageCollectorTest.php b/plugin/codecov/tests/Unit/Internal/CoverageCollectorTest.php
index 968c15ac..0ffb2f5b 100644
--- a/plugin/codecov/tests/Unit/Internal/CoverageCollectorTest.php
+++ b/plugin/codecov/tests/Unit/Internal/CoverageCollectorTest.php
@@ -8,6 +8,7 @@
use Testo\Application\Internal\EventDispatcher;
use Testo\Assert;
use Testo\Codecov\Covers;
+use Testo\Codecov\Exception\CoverageReportNotWritten;
use Testo\Codecov\Internal\CoverageCollector;
use Testo\Codecov\Report\CoverageReport;
use Testo\Codecov\Result\CoverageResult;
@@ -23,6 +24,7 @@
use Testo\Core\Value\Status;
use Testo\Data\MultipleResult;
use Testo\Event\Report\ReportFileGenerated;
+use Testo\Expect;
use Testo\Test;
#[Test]
@@ -100,6 +102,32 @@ static function (ReportFileGenerated $event) use (&$seen): void {
Assert::same($seen[1]->format, 'cobertura');
}
+ public function unwritableReportDoesNotStopTheOthers(): never
+ {
+ $dispatcher = new EventDispatcher();
+ $seen = [];
+ $dispatcher->addListener(
+ ReportFileGenerated::class,
+ static function (ReportFileGenerated $event) use (&$seen): void {
+ $seen[] = $event->info->format;
+ },
+ );
+ $collector = new CoverageCollector(
+ [self::createReport('broken', fail: true), self::createReport('clover')],
+ null,
+ $dispatcher,
+ );
+
+ Expect::exception(CoverageReportNotWritten::class)
+ ->withMessage('Unable to write the coverage report file `/tmp/broken/index.xml`: Permission denied.');
+
+ try {
+ $collector->destroy();
+ } finally {
+ Assert::same($seen, ['clover']);
+ }
+ }
+
private static function suiteOf(TestResult $result): SuiteResult
{
return new SuiteResult(
@@ -111,9 +139,9 @@ private static function suiteOf(TestResult $result): SuiteResult
/**
* @param non-empty-string $format Also spells the card's path, so two reports stay distinguishable.
*/
- private static function createReport(string $format = 'stub'): CoverageReport
+ private static function createReport(string $format = 'stub', bool $fail = false): CoverageReport
{
- return new class($format) implements CoverageReport {
+ return new class($format, $fail) implements CoverageReport {
public ?CoverageResult $result = null;
/**
@@ -121,11 +149,16 @@ private static function createReport(string $format = 'stub'): CoverageReport
*/
public function __construct(
private readonly string $format,
+ private readonly bool $fail,
) {}
#[\Override]
public function generate(CoverageResult $result): void
{
+ $this->fail and throw CoverageReportNotWritten::file(
+ (string) $this->info()->path,
+ 'Permission denied',
+ );
$this->result = $result;
}
diff --git a/plugin/codecov/tests/Unit/Internal/ReportFileTest.php b/plugin/codecov/tests/Unit/Internal/ReportFileTest.php
new file mode 100644
index 00000000..0c4e9c73
--- /dev/null
+++ b/plugin/codecov/tests/Unit/Internal/ReportFileTest.php
@@ -0,0 +1,80 @@
+root = \dirname(__DIR__, 2) . '/runtime/report_file_' . \uniqid();
+ \mkdir($this->root);
+ }
+
+ #[AfterTest]
+ public function removeRoot(): void
+ {
+ $items = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($this->root, \FilesystemIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST,
+ );
+ foreach ($items as $item) {
+ $item->isDir() ? \rmdir($item->getPathname()) : \unlink($item->getPathname());
+ }
+ \rmdir($this->root);
+ }
+
+ public function writesFileCreatingMissingParents(): void
+ {
+ $path = $this->root . '/a/b/report.xml';
+
+ ReportFile::write($path, '');
+
+ Assert::same(\file_get_contents($path), '');
+ }
+
+ public function directoryUnderFileNamesThePath(): never
+ {
+ \touch($this->root . '/file');
+ $dir = $this->root . '/file/reports';
+
+ Expect::exception(CoverageReportNotWritten::class)
+ ->withMessageContaining("Unable to create the coverage report directory `{$dir}`: ");
+
+ ReportFile::directory($dir);
+ }
+
+ public function fileOverDirectoryNamesThePath(): never
+ {
+ $path = $this->root . '/report.xml';
+ \mkdir($path);
+
+ Expect::exception(CoverageReportNotWritten::class)
+ ->withMessageContaining("Unable to write the coverage report file `{$path}`: ");
+
+ ReportFile::write($path, '');
+ }
+
+ public function silentFailureStillNamesThePath(): void
+ {
+ $e = CoverageReportNotWritten::file('/out/report.xml', '');
+
+ Assert::same($e->getMessage(), 'Unable to write the coverage report file `/out/report.xml`.');
+ }
+}
diff --git a/skills/testo-coverage/SKILL.md b/skills/testo-coverage/SKILL.md
index 333f26b3..8c181d0e 100644
--- a/skills/testo-coverage/SKILL.md
+++ b/skills/testo-coverage/SKILL.md
@@ -160,6 +160,10 @@ the table is what a consumer switches on.
consumer opens: for a report that fills a directory, the index inside it rather than the directory; for
one that uploads its data, whatever URL it lands on.
+A report that cannot write its target throws `Testo\Codecov\Exception\CoverageReportNotWritten`, naming
+the path and the filesystem's reason. The remaining reports are still written, then the run exits
+non-zero. A custom report should throw the same exception on a failed write.
+
```php
final readonly class MyReport implements CoverageReport
{
@@ -177,6 +181,7 @@ final readonly class MyReport implements CoverageReport
## Pitfalls
- No coverage written? Check the active Xdebug mode includes `coverage` — set it via `xdebug.mode`, `-d xdebug.mode=coverage`, or `XDEBUG_MODE=coverage` (or load PCOV). Testo skips the driver if neither is available.
+- A report landed in the project root under a name like `C:\Users\...`? A Windows path reached a Linux PHP (WSL, Docker): it has no `/`, so it reads as a relative file name. Pass the path as the interpreter sees it (`/mnt/c/...`).
- `clover.xml` empty? Suite-level finder probably excludes the `src` directory you expected — verify the `FinderConfig` covers it.
- Don't enable coverage in benchmark suites — it falsifies timings.
- Coverage under `#[RunInFiber]` costs an extra driver stop/start per suspension (the window is closed