diff --git a/ChangeLog-12.3.md b/ChangeLog-12.3.md index 8a7423e69..55c140d85 100644 --- a/ChangeLog-12.3.md +++ b/ChangeLog-12.3.md @@ -2,6 +2,10 @@ All notable changes are documented in this file using the [Keep a CHANGELOG](http://keepachangelog.com/) principles. +## [12.3.8] - 2025-MM-DD + +* [#1092](https://github.com/sebastianbergmann/php-code-coverage/issues/1092): Error in `DOMDocument::saveXML()` is not handled + ## [12.3.7] - 2025-09-10 ### Changed @@ -56,6 +60,7 @@ All notable changes are documented in this file using the [Keep a CHANGELOG](htt * [#1080](https://github.com/sebastianbergmann/php-code-coverage/pull/1080): Support for reporting code coverage information in OpenClover XML format; unlike the existing Clover XML reporter, which remains unchanged, the XML documents generated by this new reporter validate against the OpenClover project's XML schema definition, with one exception: we do not generate the `` element. This feature is experimental and the generated XML might change in order to improve compliance with the OpenClover project's XML schema definition further. Such changes will be made in bugfix and/or minor releases even if they break backward compatibility. +[12.3.8]: https://github.com/sebastianbergmann/php-code-coverage/compare/12.3.7...main [12.3.7]: https://github.com/sebastianbergmann/php-code-coverage/compare/12.3.6...12.3.7 [12.3.6]: https://github.com/sebastianbergmann/php-code-coverage/compare/12.3.5...12.3.6 [12.3.5]: https://github.com/sebastianbergmann/php-code-coverage/compare/12.3.4...12.3.5 diff --git a/src/Report/Clover.php b/src/Report/Clover.php index a5f1c09e6..641cd0bbb 100644 --- a/src/Report/Clover.php +++ b/src/Report/Clover.php @@ -10,31 +10,31 @@ namespace SebastianBergmann\CodeCoverage\Report; use function count; -use function dirname; -use function file_put_contents; use function is_string; use function ksort; use function max; use function range; -use function str_contains; use function time; use DOMDocument; use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\Node\File; use SebastianBergmann\CodeCoverage\Util\Filesystem; +use SebastianBergmann\CodeCoverage\Util\Xml; use SebastianBergmann\CodeCoverage\WriteOperationFailedException; final class Clover { /** + * @param null|non-empty-string $target + * @param null|non-empty-string $name + * * @throws WriteOperationFailedException */ public function process(CodeCoverage $coverage, ?string $target = null, ?string $name = null): string { $time = (string) time(); - $xmlDocument = new DOMDocument('1.0', 'UTF-8'); - $xmlDocument->formatOutput = true; + $xmlDocument = new DOMDocument('1.0', 'UTF-8'); $xmlCoverage = $xmlDocument->createElement('coverage'); $xmlCoverage->setAttribute('generated', $time); @@ -216,16 +216,10 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string $xmlMetrics->setAttribute('coveredelements', (string) ($report->numberOfTestedMethods() + $report->numberOfExecutedLines() + $report->numberOfExecutedBranches())); $xmlProject->appendChild($xmlMetrics); - $buffer = $xmlDocument->saveXML(); + $buffer = Xml::asString($xmlDocument); if ($target !== null) { - if (!str_contains($target, '://')) { - Filesystem::createDirectory(dirname($target)); - } - - if (@file_put_contents($target, $buffer) === false) { - throw new WriteOperationFailedException($target); - } + Filesystem::write($target, $buffer); } return $buffer; diff --git a/src/Report/Cobertura.php b/src/Report/Cobertura.php index 51786e5df..38653f754 100644 --- a/src/Report/Cobertura.php +++ b/src/Report/Cobertura.php @@ -12,22 +12,22 @@ use const DIRECTORY_SEPARATOR; use function basename; use function count; -use function dirname; -use function file_put_contents; use function preg_match; use function range; -use function str_contains; use function str_replace; use function time; use DOMImplementation; use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\Node\File; use SebastianBergmann\CodeCoverage\Util\Filesystem; +use SebastianBergmann\CodeCoverage\Util\Xml; use SebastianBergmann\CodeCoverage\WriteOperationFailedException; final class Cobertura { /** + * @param null|non-empty-string $target + * * @throws WriteOperationFailedException */ public function process(CodeCoverage $coverage, ?string $target = null): string @@ -44,10 +44,9 @@ public function process(CodeCoverage $coverage, ?string $target = null): string 'http://cobertura.sourceforge.net/xml/coverage-04.dtd', ); - $document = $implementation->createDocument('', '', $documentType); - $document->xmlVersion = '1.0'; - $document->encoding = 'UTF-8'; - $document->formatOutput = true; + $document = $implementation->createDocument('', '', $documentType); + $document->xmlVersion = '1.0'; + $document->encoding = 'UTF-8'; $coverageElement = $document->createElement('coverage'); @@ -289,16 +288,10 @@ public function process(CodeCoverage $coverage, ?string $target = null): string $coverageElement->setAttribute('complexity', (string) $complexity); - $buffer = $document->saveXML(); + $buffer = Xml::asString($document); if ($target !== null) { - if (!str_contains($target, '://')) { - Filesystem::createDirectory(dirname($target)); - } - - if (@file_put_contents($target, $buffer) === false) { - throw new WriteOperationFailedException($target); - } + Filesystem::write($target, $buffer); } return $buffer; diff --git a/src/Report/Crap4j.php b/src/Report/Crap4j.php index 57fabfce5..a79d0a68e 100644 --- a/src/Report/Crap4j.php +++ b/src/Report/Crap4j.php @@ -10,16 +10,14 @@ namespace SebastianBergmann\CodeCoverage\Report; use function date; -use function dirname; -use function file_put_contents; use function htmlspecialchars; use function is_string; use function round; -use function str_contains; use DOMDocument; use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\Node\File; use SebastianBergmann\CodeCoverage\Util\Filesystem; +use SebastianBergmann\CodeCoverage\Util\Xml; use SebastianBergmann\CodeCoverage\WriteOperationFailedException; final readonly class Crap4j @@ -32,12 +30,14 @@ public function __construct(int $threshold = 30) } /** + * @param null|non-empty-string $target + * @param null|non-empty-string $name + * * @throws WriteOperationFailedException */ public function process(CodeCoverage $coverage, ?string $target = null, ?string $name = null): string { - $document = new DOMDocument('1.0', 'UTF-8'); - $document->formatOutput = true; + $document = new DOMDocument('1.0', 'UTF-8'); $root = $document->createElement('crap_result'); $document->appendChild($root); @@ -119,16 +119,10 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string $root->appendChild($stats); $root->appendChild($methodsNode); - $buffer = $document->saveXML(); + $buffer = Xml::asString($document); if ($target !== null) { - if (!str_contains($target, '://')) { - Filesystem::createDirectory(dirname($target)); - } - - if (@file_put_contents($target, $buffer) === false) { - throw new WriteOperationFailedException($target); - } + Filesystem::write($target, $buffer); } return $buffer; diff --git a/src/Report/OpenClover.php b/src/Report/OpenClover.php index 042132b33..65d409b1c 100644 --- a/src/Report/OpenClover.php +++ b/src/Report/OpenClover.php @@ -12,13 +12,10 @@ use function assert; use function basename; use function count; -use function dirname; -use function file_put_contents; use function is_string; use function ksort; use function max; use function range; -use function str_contains; use function str_replace; use function time; use DOMDocument; @@ -26,6 +23,7 @@ use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\Node\File; use SebastianBergmann\CodeCoverage\Util\Filesystem; +use SebastianBergmann\CodeCoverage\Util\Xml; use SebastianBergmann\CodeCoverage\Version; use SebastianBergmann\CodeCoverage\WriteOperationFailedException; @@ -240,16 +238,10 @@ public function process(CodeCoverage $coverage, ?string $target = null, ?string $xmlMetrics->setAttribute('coveredmethods', (string) $report->numberOfTestedMethods()); $xmlProject->insertBefore($xmlMetrics, $xmlProject->firstChild); - $buffer = $xmlDocument->saveXML(); + $buffer = Xml::asString($xmlDocument); if ($target !== null) { - if (!str_contains($target, '://')) { - Filesystem::createDirectory(dirname($target)); - } - - if (@file_put_contents($target, $buffer) === false) { - throw new WriteOperationFailedException($target); - } + Filesystem::write($target, $buffer); } return $buffer; diff --git a/src/Report/PHP.php b/src/Report/PHP.php index 051f9154e..aa941dc8a 100644 --- a/src/Report/PHP.php +++ b/src/Report/PHP.php @@ -10,16 +10,18 @@ namespace SebastianBergmann\CodeCoverage\Report; use const PHP_EOL; -use function dirname; -use function file_put_contents; use function serialize; -use function str_contains; use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\Util\Filesystem; use SebastianBergmann\CodeCoverage\WriteOperationFailedException; final class PHP { + /** + * @param null|non-empty-string $target + * + * @throws WriteOperationFailedException + */ public function process(CodeCoverage $coverage, ?string $target = null): string { $coverage->clearCache(); @@ -28,13 +30,7 @@ public function process(CodeCoverage $coverage, ?string $target = null): string return \unserialize(<<<'END_OF_COVERAGE_SERIALIZATION'" . PHP_EOL . serialize($coverage) . PHP_EOL . 'END_OF_COVERAGE_SERIALIZATION' . PHP_EOL . ');'; if ($target !== null) { - if (!str_contains($target, '://')) { - Filesystem::createDirectory(dirname($target)); - } - - if (@file_put_contents($target, $buffer) === false) { - throw new WriteOperationFailedException($target); - } + Filesystem::write($target, $buffer); } return $buffer; diff --git a/src/Report/Xml/Facade.php b/src/Report/Xml/Facade.php index ee2e8aa01..ba008f2ff 100644 --- a/src/Report/Xml/Facade.php +++ b/src/Report/Xml/Facade.php @@ -10,18 +10,13 @@ namespace SebastianBergmann\CodeCoverage\Report\Xml; use const DIRECTORY_SEPARATOR; -use const PHP_EOL; use function count; use function dirname; use function file_get_contents; -use function file_put_contents; use function is_array; use function is_dir; use function is_file; use function is_writable; -use function libxml_clear_errors; -use function libxml_get_errors; -use function libxml_use_internal_errors; use function sprintf; use function strlen; use function substr; @@ -33,7 +28,8 @@ use SebastianBergmann\CodeCoverage\Node\File; use SebastianBergmann\CodeCoverage\Node\File as FileNode; use SebastianBergmann\CodeCoverage\PathExistsButIsNotDirectoryException; -use SebastianBergmann\CodeCoverage\Util\Filesystem as DirectoryUtil; +use SebastianBergmann\CodeCoverage\Util\Filesystem; +use SebastianBergmann\CodeCoverage\Util\Xml; use SebastianBergmann\CodeCoverage\Version; use SebastianBergmann\CodeCoverage\WriteOperationFailedException; use SebastianBergmann\CodeCoverage\XmlException; @@ -107,7 +103,7 @@ private function initTargetDirectory(string $directory): void // @codeCoverageIgnoreEnd } - DirectoryUtil::createDirectory($directory); + Filesystem::createDirectory($directory); } /** @@ -287,38 +283,8 @@ private function saveDocument(DOMDocument $document, string $name): void { $filename = sprintf('%s/%s.xml', $this->targetDirectory(), $name); - $document->formatOutput = true; - $document->preserveWhiteSpace = false; $this->initTargetDirectory(dirname($filename)); - file_put_contents($filename, $this->documentAsString($document)); - } - - /** - * @throws XmlException - * - * @see https://bugs.php.net/bug.php?id=79191 - */ - private function documentAsString(DOMDocument $document): string - { - $xmlErrorHandling = libxml_use_internal_errors(true); - $xml = $document->saveXML(); - - if ($xml === false) { - // @codeCoverageIgnoreStart - $message = 'Unable to generate the XML'; - - foreach (libxml_get_errors() as $error) { - $message .= PHP_EOL . $error->message; - } - - throw new XmlException($message); - // @codeCoverageIgnoreEnd - } - - libxml_clear_errors(); - libxml_use_internal_errors($xmlErrorHandling); - - return $xml; + Filesystem::write($filename, Xml::asString($document)); } } diff --git a/src/Util/Filesystem.php b/src/Util/Filesystem.php index 0e99b1593..f73388ae2 100644 --- a/src/Util/Filesystem.php +++ b/src/Util/Filesystem.php @@ -9,9 +9,13 @@ */ namespace SebastianBergmann\CodeCoverage\Util; +use function dirname; +use function file_put_contents; use function is_dir; use function mkdir; use function sprintf; +use function str_contains; +use SebastianBergmann\CodeCoverage\WriteOperationFailedException; /** * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage @@ -34,4 +38,20 @@ public static function createDirectory(string $directory): void ); } } + + /** + * @param non-empty-string $target + * + * @throws WriteOperationFailedException + */ + public static function write(string $target, string $buffer): void + { + if (!str_contains($target, '://')) { + self::createDirectory(dirname($target)); + } + + if (@file_put_contents($target, $buffer) === false) { + throw new WriteOperationFailedException($target); + } + } } diff --git a/src/Util/Xml.php b/src/Util/Xml.php new file mode 100644 index 000000000..de958a4b2 --- /dev/null +++ b/src/Util/Xml.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Util; + +use const PHP_EOL; +use function libxml_clear_errors; +use function libxml_get_errors; +use function libxml_use_internal_errors; +use DOMDocument; +use SebastianBergmann\CodeCoverage\XmlException; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final readonly class Xml +{ + /** + * @throws XmlException + * + * @see https://bugs.php.net/bug.php?id=79191 + */ + public static function asString(DOMDocument $document): string + { + $xmlErrorHandling = libxml_use_internal_errors(true); + + $document->formatOutput = true; + $document->preserveWhiteSpace = false; + + $buffer = $document->saveXML(); + + if ($buffer === false) { + $message = 'Unable to generate the XML'; + + foreach (libxml_get_errors() as $error) { + $message .= PHP_EOL . $error->message; + } + + throw new XmlException($message); + } + + libxml_clear_errors(); + libxml_use_internal_errors($xmlErrorHandling); + + return $buffer; + } +} diff --git a/tests/tests/Target/MapBuilderTest.php b/tests/tests/Target/MapBuilderTest.php index 0c03682b4..49c1c0ed6 100644 --- a/tests/tests/Target/MapBuilderTest.php +++ b/tests/tests/Target/MapBuilderTest.php @@ -418,6 +418,10 @@ public static function provider(): array ]; } + /** + * @param TargetMap $expected, + * @param non-empty-list $files + */ #[DataProvider('provider')] public function testBuildsMap(array $expected, array $files): void {