diff --git a/plugin/codecov/src/Report/PhpUnitXmlReport.php b/plugin/codecov/src/Report/PhpUnitXmlReport.php index 31aa16eb..a1f2a153 100644 --- a/plugin/codecov/src/Report/PhpUnitXmlReport.php +++ b/plugin/codecov/src/Report/PhpUnitXmlReport.php @@ -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 ``. 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 `` 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. */ @@ -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; @@ -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()); + } + } } /** diff --git a/plugin/codecov/tests/Unit/Report/PhpUnitXmlReportTest.php b/plugin/codecov/tests/Unit/Report/PhpUnitXmlReportTest.php index b4af9e68..885c3efd 100644 --- a/plugin/codecov/tests/Unit/Report/PhpUnitXmlReportTest.php +++ b/plugin/codecov/tests/Unit/Report/PhpUnitXmlReportTest.php @@ -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, ''); + + $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(); diff --git a/tests/Application/Stub/EmptyRun/.placeholder.php b/tests/Application/Stub/EmptyRun/.placeholder.php new file mode 100644 index 00000000..72680edf --- /dev/null +++ b/tests/Application/Stub/EmptyRun/.placeholder.php @@ -0,0 +1,10 @@ +