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