Skip to content
Open
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/Report/PhpUnitXmlReport.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,25 @@
*
* Distinct from JUnit XML (test-results format, see {@see \Testo\Output\JUnit\JUnitPlugin}).
*
* ## Stale-file guard
*
* On the first successful run a sentinel file `.testo-coverage` is created
* inside `<outputDir>`. On every subsequent run, the sentinel's presence
* signals that the directory belongs to this reporter; all `*.xml` files are
* removed before the new set is written. This prevents stale entries — for
* source files that were renamed or deleted between runs — from accumulating
* inside the output directory.
*
* If `<outputDir>` does not contain the sentinel (e.g. it is pointed at a
* pre-existing directory unrelated to coverage), no sweeping takes place,
* protecting against accidental data loss.
*
* @api
*/
final readonly class PhpUnitXmlReport implements CoverageReport
{
private const XMLNS = 'https://schema.phpunit.de/coverage/1.0';
private const SENTINEL = '.testo-coverage';

public function __construct(
/** @var non-empty-string Output directory for the report. Created if missing. */
Expand All @@ -47,6 +61,14 @@ public function generate(CoverageResult $result): void

\is_dir($this->outputDir) or \mkdir($this->outputDir, 0o755, true);

// Stale-file guard: sentinel presence means this directory is ours.
// Sweep leftover *.xml files before writing the new set so that
// deleted or renamed source files don't persist across runs.
$sentinelPath = $this->outputDir . '/' . self::SENTINEL;
if (\file_exists($sentinelPath)) {
$this->sweepStaleXml();
}

$entries = [];
$totalStmts = 0;
$totalCovered = 0;
Expand Down Expand Up @@ -75,6 +97,22 @@ public function generate(CoverageResult $result): void
foreach ($entries as $entry) {
$this->writeFile($entry);
}

\file_put_contents($sentinelPath, '');
}

private function sweepStaleXml(): void
{
// LEAVES_ONLY (default) yields only files, never directories.
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($this->outputDir, \RecursiveDirectoryIterator::SKIP_DOTS),
);
foreach ($iterator as $file) {
\assert($file instanceof \SplFileInfo);
if ($file->getExtension() === 'xml') {
\unlink($file->getPathname());
}
}
}

/**
Expand Down
108 changes: 108 additions & 0 deletions plugin/codecov/tests/Unit/Report/PhpUnitXmlReportTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,114 @@ public function fileOutsideSourceRootFlattensToSlug(): void
}
}

// --- Stale-file guard tests (issue #125) ---

public function sentinelIsWrittenInsideOutputDir(): void
{
$dir = self::tmpDir();
try {
$result = (new CoverageResult([
'/project/src/Foo.php' => new FileCoverage('/project/src/Foo.php', [
10 => new LineCoverage(10, LineStatus::Executed, ['Tests\\FooTest::testA']),
]),
]))->withSourceRoot('/project');

(new PhpUnitXmlReport($dir))->generate($result);

// Sentinel must be a child of outputDir, not a sibling alongside it.
Assert::true(\file_exists($dir . '/.testo-coverage'));
Assert::false(\is_file($dir . '.testo-coverage'));
} finally {
self::cleanup($dir);
// Clean up the alongside-dir sentinel if the mutant wrote it.
\is_file($dir . '.testo-coverage') and \unlink($dir . '.testo-coverage');
}
}

public function reRunSameSourceSetProducesNoStaleFiles(): void
{
$dir = self::tmpDir();
try {
$result = (new CoverageResult([
'/project/src/Foo.php' => new FileCoverage('/project/src/Foo.php', [
10 => new LineCoverage(10, LineStatus::Executed, ['Tests\\FooTest::testA']),
]),
]))->withSourceRoot('/project');

$report = new PhpUnitXmlReport($dir);
$report->generate($result);
$report->generate($result);

// Exactly one per-file XML plus index.xml; no duplicates or leftovers.
$xmlFiles = \glob($dir . '/src/*.xml') ?: [];
Assert::count($xmlFiles, 1);
Assert::true(\file_exists($dir . '/index.xml'));
Assert::same(\count(\glob($dir . '/*.xml') ?: []), 1); // index only in root
} finally {
self::cleanup($dir);
}
}

public function reRunAfterFileRemovedCleansStaleXml(): void
{
$dir = self::tmpDir();
try {
$resultWithTwo = (new CoverageResult([
'/project/src/Foo.php' => new FileCoverage('/project/src/Foo.php', [
10 => new LineCoverage(10, LineStatus::Executed, ['Tests\\FooTest::testA']),
]),
'/project/src/Bar.php' => new FileCoverage('/project/src/Bar.php', [
10 => new LineCoverage(10, LineStatus::Executed, ['Tests\\BarTest::testA']),
]),
]))->withSourceRoot('/project');

$resultWithOne = (new CoverageResult([
'/project/src/Foo.php' => new FileCoverage('/project/src/Foo.php', [
10 => new LineCoverage(10, LineStatus::Executed, ['Tests\\FooTest::testA']),
]),
]))->withSourceRoot('/project');

$report = new PhpUnitXmlReport($dir);
$report->generate($resultWithTwo);

Assert::true(\file_exists($dir . '/src/Foo.php.xml'));
Assert::true(\file_exists($dir . '/src/Bar.php.xml'));

$report->generate($resultWithOne);

// Bar.php was "deleted" — its stale XML must be gone.
Assert::true(\file_exists($dir . '/src/Foo.php.xml'));
Assert::false(\file_exists($dir . '/src/Bar.php.xml'));
} finally {
self::cleanup($dir);
}
}

public function firstRunOnNonEmptyDirectoryWithoutSentinelLeavesExistingFilesAlone(): void
{
$dir = self::tmpDir();
try {
// Pre-populate with a non-coverage XML that must survive untouched.
$foreignXml = $dir . '/some-other-report.xml';
\file_put_contents($foreignXml, '<report/>');

$result = (new CoverageResult([
'/project/src/Foo.php' => new FileCoverage('/project/src/Foo.php', [
10 => new LineCoverage(10, LineStatus::Executed, ['Tests\\FooTest::testA']),
]),
]))->withSourceRoot('/project');

(new PhpUnitXmlReport($dir))->generate($result);

// No sentinel on first run → sweep never ran → foreign file untouched.
Assert::true(\file_exists($foreignXml));
Assert::true(\file_exists($dir . '/index.xml'));
Assert::true(\file_exists($dir . '/src/Foo.php.xml'));
} finally {
self::cleanup($dir);
}
}

private static function tmpDir(): string
{
$dir = \dirname(__DIR__, 2) . '/runtime/testo_phpunit_xml_' . \uniqid();
Expand Down
10 changes: 10 additions & 0 deletions tests/Application/Stub/EmptyRun/.placeholder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

// Intentionally empty — fixture directory for EmptyRunTest.
// This file exists solely so the PHPUnit mirror build (bin/build-phpunit.php)
// copies it to tests/PhpUnit/Application/Stub/EmptyRun/, creating the directory
// that the mirrored EmptyRunTest references via __DIR__ . '/../../Stub/EmptyRun'.
// The file contains no classes or tests; the Testo application run in the test
// finds zero tests here and correctly reports Status::Risky.
Loading