Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions plugin/codecov/src/Exception/CoverageReportNotWritten.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Testo\Codecov\Exception;

/**
* Thrown when a coverage report cannot be written to its target path.
*
* @api
*/
final class CoverageReportNotWritten extends \RuntimeException
{
/**
* @param string $path The directory or file that could not be written.
* @param string $reason What the filesystem reported; empty when it said nothing.
*/
public static function directory(string $path, string $reason): self
{
return new self(self::format('Unable to create the coverage report directory', $path, $reason));
}

/**
* @param string $path The directory or file that could not be written.
* @param string $reason What the filesystem reported; empty when it said nothing.
*/
public static function file(string $path, string $reason): self
{
return new self(self::format('Unable to write the coverage report file', $path, $reason));
}

private static function format(string $action, string $path, string $reason): string
{
return $reason === ''
? \sprintf('%s `%s`.', $action, $path)
: \sprintf('%s `%s`: %s.', $action, $path, $reason);
}
}
13 changes: 12 additions & 1 deletion plugin/codecov/src/Internal/CoverageCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Internal\Container\Attribute\ScopeShared;
use Internal\Destroy\Destroyable;
use Psr\EventDispatcher\EventDispatcherInterface;
use Testo\Codecov\Exception\CoverageReportNotWritten;
use Testo\Codecov\Result\CoverageResult;
use Testo\Codecov\Report\CoverageReport;
use Testo\Core\Context\SuiteResult;
Expand Down Expand Up @@ -59,10 +60,20 @@ public function destroy(): void
? $this->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;
}

/**
Expand Down
57 changes: 57 additions & 0 deletions plugin/codecov/src/Internal/ReportFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Testo\Codecov\Internal;

use Testo\Codecov\Exception\CoverageReportNotWritten;

/**
* Filesystem writes shared by the coverage reports.
*
* @internal
* @psalm-internal Testo\Codecov
*/
final class ReportFile
{
/**
* Writes the file, creating its parent directory when missing.
*
* @throws CoverageReportNotWritten
*/
public static function write(string $path, string $content): void
{
self::directory(\dirname($path));

\error_clear_last();
@\file_put_contents($path, $content) === false and throw CoverageReportNotWritten::file(
$path,
self::lastError(),
);
}

/**
* Creates the directory with its parents when missing.
*
* @throws CoverageReportNotWritten
*/
public static function directory(string $path): void
{
\error_clear_last();
# The second `is_dir()` covers a directory created concurrently between the check and `mkdir()`.
\is_dir($path) || @\mkdir($path, 0o755, true) || \is_dir($path) or throw CoverageReportNotWritten::directory(
$path,
self::lastError(),
);
}

/**
* The last PHP warning without its `function(): ` prefix, e.g. `Permission denied`.
*/
private static function lastError(): string
{
$message = \error_get_last()['message'] ?? '';

return \preg_replace('/^\w+\([^)]*\): /', '', $message) ?? $message;
}
}
5 changes: 2 additions & 3 deletions plugin/codecov/src/Report/CloverReport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -75,9 +76,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]
Expand Down
5 changes: 2 additions & 3 deletions plugin/codecov/src/Report/CoberturaReport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions plugin/codecov/src/Report/CoverageReport.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Testo\Codecov\Report;

use Testo\Codecov\Exception\CoverageReportNotWritten;
use Testo\Codecov\Result\CoverageResult;
use Testo\Core\Report\ReportInfo;

Expand All @@ -14,6 +15,9 @@
*/
interface CoverageReport
{
/**
* @throws CoverageReportNotWritten When the target path cannot be written.
*/
public function generate(CoverageResult $result): void;

/**
Expand Down
10 changes: 4 additions & 6 deletions plugin/codecov/src/Report/PhpUnitXmlReport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}

/**
Expand All @@ -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');

Expand Down Expand Up @@ -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());
}
}
37 changes: 35 additions & 2 deletions plugin/codecov/tests/Unit/Internal/CoverageCollectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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]
Expand Down Expand Up @@ -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(
Expand All @@ -111,21 +139,26 @@ 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;

/**
* @param non-empty-string $format
*/
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;
}

Expand Down
80 changes: 80 additions & 0 deletions plugin/codecov/tests/Unit/Internal/ReportFileTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace Tests\Codecov\Unit\Internal;

use Testo\Assert;
use Testo\Codecov\Covers;
use Testo\Codecov\Exception\CoverageReportNotWritten;
use Testo\Codecov\Internal\ReportFile;
use Testo\Expect;
use Testo\Lifecycle\AfterTest;
use Testo\Lifecycle\BeforeTest;
use Testo\Test;

#[Test]
#[Covers(ReportFile::class)]
#[Covers(CoverageReportNotWritten::class)]
final class ReportFileTest
{
private string $root;

#[BeforeTest]
public function createRoot(): void
{
$this->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, '<coverage/>');

Assert::same(\file_get_contents($path), '<coverage/>');
}

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, '<coverage/>');
}

public function silentFailureStillNamesThePath(): void
{
$e = CoverageReportNotWritten::file('/out/report.xml', '');

Assert::same($e->getMessage(), 'Unable to write the coverage report file `/out/report.xml`.');
}
}
Loading
Loading