* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace PHPUnit\Logging\OpenTestReporting; use const PHP_VERSION; use const ZEND_THREAD_SAFE; use function array_pop; use function assert; use function count; use function error_get_last; use function str_replace; use DateTimeImmutable; use DateTimeZone; use PHPUnit\Event\Code\Test; use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\Throwable; use PHPUnit\Event\Facade; use PHPUnit\Event\Test\AfterLastTestMethodErrored; use PHPUnit\Event\Test\AfterLastTestMethodFailed; use PHPUnit\Event\Test\BeforeFirstTestMethodErrored; use PHPUnit\Event\Test\BeforeFirstTestMethodFailed; use PHPUnit\Event\Test\Errored; use PHPUnit\Event\Test\Failed; use PHPUnit\Event\Test\MarkedIncomplete; use PHPUnit\Event\Test\PreparationErrored; use PHPUnit\Event\Test\PreparationFailed; use PHPUnit\Event\Test\Prepared as TestStarted; use PHPUnit\Event\Test\Skipped; use PHPUnit\Event\TestSuite\Skipped as TestSuiteSkipped; use PHPUnit\Event\TestSuite\Started as TestSuiteStarted; use PHPUnit\Event\TestSuite\TestSuiteForTestClass; use XMLWriter; /** * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit * * @internal This class is not covered by the backward compatibility promise for PHPUnit */ final class OtrXmlLogger { private readonly XMLWriter $writer; /** * @var non-negative-int */ private int $idSequence = 0; /** * @var ?positive-int */ private ?int $parentId = null; /** * @var list */ private array $parentIdStack = []; /** * @var ?positive-int */ private ?int $testId = null; private ?Throwable $parentErrored = null; private ?Throwable $parentFailed = null; private bool $alreadyFinished = false; private bool $includeGitInformation; /** * @param non-empty-string $uri * * @throws CannotOpenUriForWritingException */ public function __construct(Facade $facade, string $uri, bool $includeGitInformation) { $this->writer = new XMLWriter; if (@$this->writer->openUri($uri) === false) { throw new CannotOpenUriForWritingException( str_replace('XMLWriter::openUri(): ', '', error_get_last()['message']), ); } $this->writer->setIndent(true); $this->registerSubscribers($facade); $this->includeGitInformation = $includeGitInformation; } public function testRunnerStarted(): void { $infrastructure = new InfrastructureInformationProvider; $gitInformation = false; if ($this->includeGitInformation) { $gitInformation = $infrastructure->gitInformation(); } $this->writer->startDocument(); $this->writer->startElement('e:events'); $this->writer->writeAttribute('xmlns', 'https://schemas.opentest4j.org/reporting/core/0.2.0'); $this->writer->writeAttribute('xmlns:e', 'https://schemas.opentest4j.org/reporting/events/0.2.0'); if ($gitInformation !== false) { $this->writer->writeAttribute('xmlns:git', 'https://schemas.opentest4j.org/reporting/git/0.2.0'); } $this->writer->writeAttribute('xmlns:php', 'https://schema.phpunit.de/otr/php/0.0.1'); $this->writer->writeAttribute('xmlns:phpunit', 'https://schema.phpunit.de/otr/phpunit/0.0.1'); $this->writer->startElement('infrastructure'); $this->writer->writeElement('hostName', $infrastructure->hostName()); $this->writer->writeElement('userName', $infrastructure->userName()); $this->writer->writeElement('operatingSystem', $infrastructure->operatingSystem()); $this->writer->writeElement('php:phpVersion', PHP_VERSION); $this->writer->writeElement('php:threadModel', ZEND_THREAD_SAFE ? 'ZTS' : 'NTS'); if ($gitInformation !== false) { $this->writer->startElement('git:repository'); $this->writer->writeAttribute('originUrl', $gitInformation['originUrl']); $this->writer->endElement(); $this->writer->writeElement('git:branch', $gitInformation['branch']); $this->writer->writeElement('git:commit', $gitInformation['commit']); $this->writer->startElement('git:status'); $this->writer->writeAttribute('clean', $gitInformation['clean'] === true ? 'true' : 'false'); $this->writer->writeCdata($gitInformation['status']); $this->writer->endElement(); } $this->writer->endElement(); $this->writer->flush(); } public function testRunnerFinished(): void { $this->writer->endDocument(); $this->writer->flush(); } public function testSuiteStarted(TestSuiteStarted $event): void { $id = $this->nextId(); $this->writer->startElement('e:started'); $this->writer->writeAttribute('id', (string) $id); if ($this->parentId !== null) { $this->writer->writeAttribute('parentId', (string) $this->parentId); } $testSuite = $event->testSuite(); $this->writer->writeAttribute('name', $testSuite->name()); $this->writer->writeAttribute('time', $this->timestamp()); if ($testSuite->isForTestClass()) { assert($testSuite instanceof TestSuiteForTestClass); $this->writer->startElement('sources'); $this->writer->startElement('fileSource'); $this->writer->writeAttribute('path', $testSuite->file()); $this->writer->startElement('filePosition'); $this->writer->writeAttribute('line', (string) $testSuite->line()); $this->writer->endElement(); $this->writer->endElement(); $this->writer->startElement('phpunit:classSource'); $this->writer->writeAttribute('className', $testSuite->className()); $this->writer->endElement(); $this->writer->endElement(); } $this->writer->endElement(); $this->writer->flush(); $this->parentId = $id; $this->parentIdStack[] = $id; } public function testSuiteSkipped(TestSuiteSkipped $event): void { $this->writer->startElement('e:finished'); $this->writer->writeAttribute('id', (string) $this->parentId); $this->writer->writeAttribute('time', $this->timestamp()); $this->writer->startElement('result'); $this->writer->writeAttribute('status', 'SKIPPED'); $this->writer->writeElement('reason', $event->message()); $this->writer->endElement(); $this->writer->endElement(); $this->writer->flush(); $this->reduceTestSuiteLevel(); } public function testSuiteFinished(): void { $this->writer->startElement('e:finished'); $this->writer->writeAttribute('id', (string) $this->parentId); $this->writer->writeAttribute('time', $this->timestamp()); if ($this->parentErrored !== null) { $this->writer->startElement('result'); $this->writer->writeAttribute('status', 'ERRORED'); $this->writer->writeElement('reason', $this->parentErrored->message()); $this->writeThrowable($this->parentErrored, false); $this->writer->endElement(); } elseif ($this->parentFailed !== null) { $this->writer->startElement('result'); $this->writer->writeAttribute('status', 'FAILED'); $this->writer->writeElement('reason', $this->parentFailed->message()); $this->writeThrowable($this->parentFailed, true); $this->writer->endElement(); } $this->writer->endElement(); $this->writer->flush(); $this->parentErrored = null; $this->parentFailed = null; $this->reduceTestSuiteLevel(); } public function testPrepared(PreparationErrored|PreparationFailed|TestStarted $event): void { $this->testId = $this->nextId(); $this->writeTestStarted( $event->test(), $this->testId, $this->parentId, ); } public function testFinished(): void { if (!$this->alreadyFinished) { $this->writer->startElement('e:finished'); $this->writer->writeAttribute('id', (string) $this->testId); $this->writer->writeAttribute('time', $this->timestamp()); $this->writer->startElement('result'); $this->writer->writeAttribute('status', Status::Successful->value); $this->writer->endElement(); $this->writer->endElement(); } $this->alreadyFinished = false; $this->testId = null; } public function testFailed(Failed $event): void { $this->writer->startElement('e:finished'); $this->writer->writeAttribute('id', (string) $this->testId); $this->writer->writeAttribute('time', $this->timestamp()); $this->writer->startElement('result'); $this->writer->writeAttribute('status', Status::Failed->value); $this->writer->writeElement('reason', $event->throwable()->message()); $this->writeThrowable($event->throwable(), true); $this->writer->endElement(); $this->writer->endElement(); $this->writer->flush(); $this->alreadyFinished = true; } public function testErrored(Errored $event): void { $this->writer->startElement('e:finished'); $this->writer->writeAttribute('id', (string) $this->testId); $this->writer->writeAttribute('time', $this->timestamp()); $this->writer->startElement('result'); $this->writer->writeAttribute('status', Status::Errored->value); $this->writer->writeElement('reason', $event->throwable()->message()); $this->writeThrowable($event->throwable(), false); $this->writer->endElement(); $this->writer->endElement(); $this->writer->flush(); $this->alreadyFinished = true; } public function testSkipped(Skipped $event): void { if ($this->testId === null) { $this->testId = $this->nextId(); $this->writeTestStarted( $event->test(), $this->testId, $this->parentId, ); } $this->writer->startElement('e:finished'); $this->writer->writeAttribute('id', (string) $this->testId); $this->writer->writeAttribute('time', $this->timestamp()); $this->writer->startElement('result'); $this->writer->writeAttribute('status', Status::Skipped->value); $this->writer->writeElement('reason', $event->message()); $this->writer->endElement(); $this->writer->endElement(); $this->writer->flush(); $this->alreadyFinished = true; } public function markTestIncomplete(MarkedIncomplete $event): void { $this->writer->startElement('e:finished'); $this->writer->writeAttribute('id', (string) $this->testId); $this->writer->writeAttribute('time', $this->timestamp()); $this->writer->startElement('result'); $this->writer->writeAttribute('status', Status::Aborted->value); $this->writer->writeElement('reason', $event->throwable()->message()); $this->writeThrowable($event->throwable(), false); $this->writer->endElement(); $this->writer->endElement(); $this->writer->flush(); $this->alreadyFinished = true; } public function parentErrored(AfterLastTestMethodErrored|BeforeFirstTestMethodErrored $event): void { $this->parentErrored = $event->throwable(); } public function parentFailed(AfterLastTestMethodFailed|BeforeFirstTestMethodFailed $event): void { $this->parentFailed = $event->throwable(); } private function registerSubscribers(Facade $facade): void { $facade->registerSubscribers( new TestRunnerStartedSubscriber($this), new TestSuiteStartedSubscriber($this), new TestSuiteSkippedSubscriber($this), new BeforeFirstTestMethodErroredSubscriber($this), new BeforeFirstTestMethodFailedSubscriber($this), new AfterLastTestMethodErroredSubscriber($this), new AfterLastTestMethodFailedSubscriber($this), new TestPreparationErroredSubscriber($this), new TestPreparationFailedSubscriber($this), new TestPreparedSubscriber($this), new TestAbortedSubscriber($this), new TestErroredSubscriber($this), new TestFailedSubscriber($this), new TestSkippedSubscriber($this), new TestFinishedSubscriber($this), new TestSuiteFinishedSubscriber($this), new TestRunnerFinishedSubscriber($this), ); } /** * @param positive-int $id * @param ?positive-int $parentId */ private function writeTestStarted(Test $test, int $id, ?int $parentId): void { $this->writer->startElement('e:started'); $this->writer->writeAttribute('id', (string) $id); if ($parentId !== null) { $this->writer->writeAttribute('parentId', (string) $parentId); } $this->writer->writeAttribute('name', $test->name()); $this->writer->writeAttribute('time', $this->timestamp()); $this->writer->startElement('sources'); $this->writer->startElement('fileSource'); $this->writer->writeAttribute('path', $test->file()); if ($test->isTestMethod()) { assert($test instanceof TestMethod); $this->writer->startElement('filePosition'); $this->writer->writeAttribute('line', (string) $test->line()); $this->writer->endElement(); } $this->writer->endElement(); if ($test->isTestMethod()) { assert($test instanceof TestMethod); $this->writer->startElement('phpunit:methodSource'); $this->writer->writeAttribute('className', $test->className()); $this->writer->writeAttribute('methodName', $test->methodName()); $this->writer->endElement(); } $this->writer->endElement(); $this->writer->endElement(); $this->writer->flush(); } private function writeThrowable(Throwable $throwable, bool $assertionError): void { $this->writer->startElement('phpunit:throwable'); $this->writer->writeAttribute('type', $throwable->className()); $this->writer->writeAttribute('assertionError', $assertionError ? 'true' : 'false'); $this->writer->writeCdata($throwable->asString()); $this->writer->endElement(); } /** * @return non-empty-string */ private function timestamp(): string { return (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.u\Z'); } /** * @return positive-int */ private function nextId(): int { return ++$this->idSequence; } private function reduceTestSuiteLevel(): void { array_pop($this->parentIdStack); if ($this->parentIdStack !== []) { $this->parentId = $this->parentIdStack[count($this->parentIdStack) - 1]; return; } $this->parentId = null; } }