From e97ccc3480db4ffaf35a6f0de52aee6da9f9a667 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 17:11:56 +0200 Subject: [PATCH 1/7] Add a throw counter for tests Exceptions that are thrown and caught on a normal encode or decode are invisible to the suite. ThrowCounter records every throw while a callable runs, using Xdebug's exception trace in develop mode, and assertNoThrows() fails with the throws grouped per site. Without develop mode the assertion skips, so the regular suite behaves as before. The canary test makes sure the trace parser still matches after an Xdebug upgrade. composer test:throws runs the no-throws group under develop mode and fails when its tests are skipped. --- composer.json | 3 + tests/Support/Throws/AssertsNoThrows.php | 39 +++++++++ tests/Support/Throws/ThrowCounter.php | 83 ++++++++++++++++++ tests/Support/Throws/ThrowEvent.php | 75 ++++++++++++++++ tests/Support/Throws/ThrowFrame.php | 14 +++ .../Throws/XdebugExceptionTraceParser.php | 85 +++++++++++++++++++ .../Unit/Support/Throws/ThrowCounterTest.php | 73 ++++++++++++++++ 7 files changed, 372 insertions(+) create mode 100644 tests/Support/Throws/AssertsNoThrows.php create mode 100644 tests/Support/Throws/ThrowCounter.php create mode 100644 tests/Support/Throws/ThrowEvent.php create mode 100644 tests/Support/Throws/ThrowFrame.php create mode 100644 tests/Support/Throws/XdebugExceptionTraceParser.php create mode 100644 tests/Unit/Support/Throws/ThrowCounterTest.php diff --git a/composer.json b/composer.json index a1e258e..5002670 100644 --- a/composer.json +++ b/composer.json @@ -52,6 +52,9 @@ "guzzlehttp/guzzle": "^7.8", "phpbench/phpbench": "^1.6" }, + "scripts": { + "test:throws": "@php -d xdebug.mode=develop vendor/bin/phpunit --group no-throws --fail-on-skipped" + }, "config": { "allow-plugins": { "php-http/discovery": true diff --git a/tests/Support/Throws/AssertsNoThrows.php b/tests/Support/Throws/AssertsNoThrows.php new file mode 100644 index 0000000..55a3e02 --- /dev/null +++ b/tests/Support/Throws/AssertsNoThrows.php @@ -0,0 +1,39 @@ +describe(); + $grouped[$key] = ($grouped[$key] ?? 0) + 1; + } + arsort($grouped); + + $lines = []; + foreach ($grouped as $description => $count) { + $lines[] = sprintf('%5dx %s', $count, $description); + } + + static::assertCount( + 0, + $events, + sprintf("Expected no exceptions to be thrown, got %d:\n%s", count($events), implode("\n", $lines)) + ); + } +} diff --git a/tests/Support/Throws/ThrowCounter.php b/tests/Support/Throws/ThrowCounter.php new file mode 100644 index 0000000..0a87776 --- /dev/null +++ b/tests/Support/Throws/ThrowCounter.php @@ -0,0 +1,83 @@ + + */ + public static function enable(): array + { + if (!self::isAvailable()) { + throw new RuntimeException('Counting throws needs Xdebug with xdebug.mode=develop.'); + } + + $previous = []; + foreach ([ + 'display_errors' => '1', + 'log_errors' => '0', + 'html_errors' => '0', + 'xdebug.var_display_max_children' => '0', + 'xdebug.var_display_max_data' => '0', + 'xdebug.var_display_max_depth' => '0', + 'xdebug.show_exception_trace' => '1', + ] as $key => $value) { + $old = ini_set($key, $value); + if ($old !== false) { + $previous[$key] = $old; + } + } + + return $previous; + } + + /** + * @param array $previous + */ + public static function restore(array $previous): void + { + foreach ($previous as $key => $value) { + ini_set($key, $value); + } + } + + /** + * @return list + */ + public static function record(callable $operation): array + { + $previous = self::enable(); + ob_start(); + try { + $operation(); + } finally { + $output = (string) ob_get_clean(); + self::restore($previous); + } + + return XdebugExceptionTraceParser::parse($output); + } +} diff --git a/tests/Support/Throws/ThrowEvent.php b/tests/Support/Throws/ThrowEvent.php new file mode 100644 index 0000000..5ed6a8a --- /dev/null +++ b/tests/Support/Throws/ThrowEvent.php @@ -0,0 +1,75 @@ + $class + * @param list $frames Outermost call first, as Xdebug prints them. + */ + public function __construct( + public readonly string $class, + public readonly string $message, + public readonly string $file, + public readonly int $line, + public readonly array $frames, + ) { + } + + public function site(): string + { + return self::relative($this->file).':'.$this->line; + } + + /** + * The innermost place in src/ that led to this throw, or null when the stack never passes through src/. + */ + public function srcCaller(): ?string + { + if (self::isSrc($this->file)) { + return $this->site(); + } + + foreach (array_reverse($this->frames) as $frame) { + if (self::isSrc($frame->file)) { + return self::relative($frame->file).':'.$frame->line.' -> '.$frame->function; + } + } + + return null; + } + + public function describe(): string + { + $caller = $this->srcCaller(); + + return sprintf('%s at %s%s', $this->class, $this->site(), $caller !== null ? ' via '.$caller : ''); + } + + private static function isSrc(string $file): bool + { + return str_starts_with($file, self::root().'/src/'); + } + + private static function relative(string $file): string + { + $root = self::root().'/'; + + return str_starts_with($file, $root) ? substr($file, strlen($root)) : $file; + } + + private static function root(): string + { + return dirname(__DIR__, 3); + } +} diff --git a/tests/Support/Throws/ThrowFrame.php b/tests/Support/Throws/ThrowFrame.php new file mode 100644 index 0000000..08d87f1 --- /dev/null +++ b/tests/Support/Throws/ThrowFrame.php @@ -0,0 +1,14 @@ + + */ + public static function parse(string $output): array + { + $events = []; + $header = null; + $event = null; + + foreach (explode("\n", $output) as $line) { + if ($event !== null) { + if (preg_match('/^\s+[\d.]+\s+\d+\s+\d+\. (.+?)\(.*\) (.+):(\d+)$/', $line, $frame)) { + $event['frames'][] = new ThrowFrame($frame[1], $frame[2], (int) $frame[3]); + continue; + } + + $events[] = new ThrowEvent($event['class'], $event['message'], $event['file'], $event['line'], $event['frames']); + $event = null; + } + + if ($header !== null) { + if ($line === 'Call Stack:') { + $event = self::parseHeader($header); + $header = null; + } else { + $header .= "\n".$line; + } + continue; + } + + if (preg_match('/^([A-Za-z_\\\\][\w\\\\]*): /', $line, $match) && is_a($match[1], Throwable::class, true)) { + $header = $line; + } + } + + if ($event !== null) { + $events[] = new ThrowEvent($event['class'], $event['message'], $event['file'], $event['line'], $event['frames']); + } + + return $events; + } + + /** + * @return array{class: class-string, message: string, file: string, line: int, frames: list}|null + */ + private static function parseHeader(string $header): ?array + { + if (!preg_match('/^([A-Za-z_\\\\][\w\\\\]*): (.*) in (.+) on line (\d+)\s*$/s', $header, $match) + || !is_a($match[1], Throwable::class, true) + ) { + return null; + } + + return [ + 'class' => $match[1], + 'message' => trim($match[2]), + 'file' => $match[3], + 'line' => (int) $match[4], + 'frames' => [], + ]; + } +} diff --git a/tests/Unit/Support/Throws/ThrowCounterTest.php b/tests/Unit/Support/Throws/ThrowCounterTest.php new file mode 100644 index 0000000..064993c --- /dev/null +++ b/tests/Unit/Support/Throws/ThrowCounterTest.php @@ -0,0 +1,73 @@ +class); + static::assertSame("canary\nspanning two lines", $events[0]->message); + static::assertSame(__FILE__, $events[0]->file); + static::assertSame($line, $events[0]->line); + static::assertNotEmpty($events[0]->frames); + } + + public function test_it_passes_when_nothing_is_thrown(): void + { + static::assertNoThrows(static fn (): int => 1 + 1); + } + + public function test_it_fails_with_the_throws_grouped_by_site(): void + { + try { + static::assertNoThrows(static function (): void { + for ($i = 0; $i < 2; $i++) { + try { + throw new RuntimeException('expected'); + } catch (RuntimeException) { + } + } + }); + } catch (AssertionFailedError $failure) { + static::assertStringContainsString('got 2', $failure->getMessage()); + static::assertStringContainsString( + '2x RuntimeException at tests/Unit/Support/Throws/ThrowCounterTest.php:', + $failure->getMessage() + ); + + return; + } + + static::fail('assertNoThrows did not fail on thrown exceptions.'); + } +} From 2b977fa239766d5be718a4c7d72187f45ff56b6a Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 17:12:04 +0200 Subject: [PATCH 2/7] Run the throw counting tests on CI The existing jobs use pcov, so the no-throws group only skips there. This job runs it on PHP 8.4 with Xdebug. --- .github/workflows/throws.yaml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/throws.yaml diff --git a/.github/workflows/throws.yaml b/.github/workflows/throws.yaml new file mode 100644 index 0000000..336b9fe --- /dev/null +++ b/.github/workflows/throws.yaml @@ -0,0 +1,26 @@ +name: Throws + +on: [push, pull_request] +jobs: + run: + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: [ubuntu-latest] + php-versions: [ '8.4' ] + fail-fast: false + name: Intermediate throws on PHP ${{ matrix.php-versions }} @ ${{ matrix.operating-system }} + steps: + - name: Checkout + uses: actions/checkout@master + - name: Install PHP + uses: shivammathur/setup-php@master + with: + php-version: ${{ matrix.php-versions }} + tools: 'composer:v2' + extensions: mbstring, posix, dom, soap + coverage: xdebug + - name: Install dependencies + run: composer update --prefer-dist --no-progress --no-suggest + - name: Run the throw counting tests + run: composer test:throws From abb79a2e55412319fc3105061face46c24c8fa52 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 17:12:05 +0200 Subject: [PATCH 3/7] Add a report of intermediate throws over the suite composer throws:report runs the whole suite under Xdebug develop mode with a PHPUnit extension that attributes every throw to its test and site. It only counts throws whose stack passes through src/, in tests that do not expect an exception, so the test helpers and the metadata building in wsdl-reader stay out of the numbers. Xdebug prints the traces into the output buffer of the test, which makes every throwing test risky. The extension is only registered through the script, which runs with --no-results and --do-not-fail-on-risky; the default configuration and its strict output checks are unchanged. --- composer.json | 3 +- tests/Support/Throws/ThrowAttribution.php | 134 ++++++++++++++++++ .../Throws/ThrowAttributionExtension.php | 112 +++++++++++++++ 3 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 tests/Support/Throws/ThrowAttribution.php create mode 100644 tests/Support/Throws/ThrowAttributionExtension.php diff --git a/composer.json b/composer.json index 5002670..bc4c53e 100644 --- a/composer.json +++ b/composer.json @@ -53,7 +53,8 @@ "phpbench/phpbench": "^1.6" }, "scripts": { - "test:throws": "@php -d xdebug.mode=develop vendor/bin/phpunit --group no-throws --fail-on-skipped" + "test:throws": "@php -d xdebug.mode=develop vendor/bin/phpunit --group no-throws --fail-on-skipped", + "throws:report": "@php -d xdebug.mode=develop vendor/bin/phpunit --no-progress --no-results --do-not-fail-on-risky --extension 'Soap\\Encoding\\Test\\Support\\Throws\\ThrowAttributionExtension'" }, "config": { "allow-plugins": { diff --git a/tests/Support/Throws/ThrowAttribution.php b/tests/Support/Throws/ThrowAttribution.php new file mode 100644 index 0000000..d7b7090 --- /dev/null +++ b/tests/Support/Throws/ThrowAttribution.php @@ -0,0 +1,134 @@ +|null */ + private ?array $previousIni = null; + private string $currentTest = ''; + private bool $currentExpectsException = false; + private int $all = 0; + private int $outsideSrc = 0; + private int $inTestsExpectingExceptions = 0; + /** @var array}> */ + private array $sites = []; + /** @var array */ + private array $tests = []; + /** @var array */ + private array $unsuccessfulTests = []; + + public function testStarted(Test $test): void + { + $this->currentTest = $test->id(); + $this->currentExpectsException = $test instanceof TestMethod && self::expectsException($test); + $this->previousIni ??= ThrowCounter::enable(); + } + + public function testPrinted(string $output): void + { + foreach (XdebugExceptionTraceParser::parse($output) as $event) { + $this->all++; + if ($this->currentExpectsException) { + $this->inTestsExpectingExceptions++; + continue; + } + if ($event->srcCaller() === null) { + $this->outsideSrc++; + continue; + } + + $site = $event->describe(); + $this->sites[$site] ??= ['throws' => 0, 'tests' => []]; + $this->sites[$site]['throws']++; + $this->sites[$site]['tests'][$this->currentTest] = true; + $this->tests[$this->currentTest] = true; + } + } + + public function testUnsuccessful(Test $test): void + { + $this->unsuccessfulTests[$test->id()] = true; + $this->stopTracing(); + } + + /** + * Called as soon as the outcome of a test is known: PHPUnit can throw again while it handles a skipped or + * failed test, after the output buffer of the test is closed, which would print the trace to the console. + */ + public function stopTracing(): void + { + if ($this->previousIni !== null) { + ThrowCounter::restore($this->previousIni); + $this->previousIni = null; + } + } + + public function render(): string + { + $sites = $this->sites; + uasort($sites, static fn (array $a, array $b): int => $b['throws'] <=> $a['throws']); + + $lines = [ + sprintf( + 'Intermediate throws: %d in %d tests', + $this->all - $this->outsideSrc - $this->inTestsExpectingExceptions, + count($this->tests), + ), + sprintf( + 'Not counted: %d outside src/, %d in tests that expect an exception (%d throws in total)', + $this->outsideSrc, + $this->inTestsExpectingExceptions, + $this->all, + ), + ]; + if ($this->unsuccessfulTests !== []) { + $lines[] = sprintf('Warning: %d tests failed or errored, run composer test to see them', count($this->unsuccessfulTests)); + } + $lines[] = ''; + $lines[] = sprintf('%7s %6s %s', 'throws', 'tests', 'site'); + foreach ($sites as $site => $count) { + $lines[] = sprintf('%7d %6d %s', $count['throws'], count($count['tests']), $site); + } + + return implode("\n", $lines)."\n"; + } + + /** + * PHPUnit does not report expectException() as an event, so the test method body is checked instead. + * A test that catches one of the exceptions this package reports errors with counts as expecting one too. + */ + private static function expectsException(TestMethod $test): bool + { + $method = new ReflectionMethod($test->className(), $test->methodName()); + $file = $method->getFileName(); + $start = $method->getStartLine(); + $end = $method->getEndLine(); + if ($file === false || $start === false || $end === false) { + return false; + } + + $body = implode('', array_slice(file($file) ?: [], $start - 1, $end - $start + 1)); + + return preg_match( + '/\bexpectException\w*\s*\(|\bcatch\s*\(\s*\\\\?(?:[\w\\\\]*\\\\)?(?:EncodingException|RestrictionException|SoapFaultException|Throwable|Exception)(?![\w\\\\])/', + $body + ) === 1; + } +} diff --git a/tests/Support/Throws/ThrowAttributionExtension.php b/tests/Support/Throws/ThrowAttributionExtension.php new file mode 100644 index 0000000..35b364e --- /dev/null +++ b/tests/Support/Throws/ThrowAttributionExtension.php @@ -0,0 +1,112 @@ +registerSubscribers( + new class($attribution) implements PreparationStartedSubscriber { + public function __construct(private readonly ThrowAttribution $attribution) + { + } + + public function notify(PreparationStarted $event): void + { + $this->attribution->testStarted($event->test()); + } + }, + new class($attribution) implements PrintedUnexpectedOutputSubscriber { + public function __construct(private readonly ThrowAttribution $attribution) + { + } + + public function notify(PrintedUnexpectedOutput $event): void + { + $this->attribution->testPrinted($event->output()); + } + }, + new class($attribution) implements FailedSubscriber { + public function __construct(private readonly ThrowAttribution $attribution) + { + } + + public function notify(Failed $event): void + { + $this->attribution->testUnsuccessful($event->test()); + } + }, + new class($attribution) implements ErroredSubscriber { + public function __construct(private readonly ThrowAttribution $attribution) + { + } + + public function notify(Errored $event): void + { + $this->attribution->testUnsuccessful($event->test()); + } + }, + new class($attribution) implements SkippedSubscriber { + public function __construct(private readonly ThrowAttribution $attribution) + { + } + + public function notify(Skipped $event): void + { + $this->attribution->stopTracing(); + } + }, + new class($attribution) implements FinishedSubscriber { + public function __construct(private readonly ThrowAttribution $attribution) + { + } + + public function notify(Finished $event): void + { + $this->attribution->stopTracing(); + } + }, + new class($attribution) implements ExecutionFinishedSubscriber { + public function __construct(private readonly ThrowAttribution $attribution) + { + } + + public function notify(ExecutionFinished $event): void + { + $this->attribution->stopTracing(); + print "\n".$this->attribution->render(); + } + }, + ); + } +} From d27b322eb0d687864fec6eb8b31086c53ea63e89 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 17:12:17 +0200 Subject: [PATCH 4/7] Add sparse decode benchmarks and absent value tests Taken from #70 without its src changes, so the stack that removes the intermediate throws has a baseline on main to compare against. Both tests pass on main today. Co-authored-by: Ivan Ternovtsii --- benchmarks/ComplexTypeBenchTrait.php | 8 +++++ benchmarks/DecodeBench.php | 16 +++++++++ tests/Unit/Encoder/ObjectEncoderTest.php | 44 ++++++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/benchmarks/ComplexTypeBenchTrait.php b/benchmarks/ComplexTypeBenchTrait.php index fac22d7..5b9acc3 100644 --- a/benchmarks/ComplexTypeBenchTrait.php +++ b/benchmarks/ComplexTypeBenchTrait.php @@ -23,6 +23,7 @@ trait ComplexTypeBenchTrait /** @var list */ protected array $items; protected SoapResponse $literalResponse; + protected SoapResponse $literalSparseResponse; protected SoapResponse $encodedResponse; protected BenchSoapClient $extSoapLiteral; @@ -77,6 +78,13 @@ public function setUp(): void $this->encodedResponse = new SoapResponse( $this->encodedDriver->encode('test', [$this->singleItem])->getRequest() ); + // Real-world responses often leave most elements out: only 2 of the 12 elements are present here. + $this->literalSparseResponse = new SoapResponse( + '' + . '' + . '42
Springfield
' + . '
' + ); // ext-soap $this->extSoapLiteral = $this->buildExtSoapClient('literal'); diff --git a/benchmarks/DecodeBench.php b/benchmarks/DecodeBench.php index 1ace581..cfd113b 100644 --- a/benchmarks/DecodeBench.php +++ b/benchmarks/DecodeBench.php @@ -41,6 +41,22 @@ public function benchLiteral_x500(): void } } + #[Revs(100)] + #[Groups(['literal', 'php-soap', 'x1', 'sparse'])] + public function benchLiteralSparse_x1(): void + { + $this->literalDriver->decode('test', $this->literalSparseResponse); + } + + #[Groups(['literal', 'php-soap', 'x500', 'sparse'])] + public function benchLiteralSparse_x500(): void + { + $response = $this->literalSparseResponse; + foreach ($this->items as $item) { + $this->literalDriver->decode('test', $response); + } + } + // -- php-soap/encoding: encoded -- #[Revs(100)] diff --git a/tests/Unit/Encoder/ObjectEncoderTest.php b/tests/Unit/Encoder/ObjectEncoderTest.php index b6f9fc3..dcfab6f 100644 --- a/tests/Unit/Encoder/ObjectEncoderTest.php +++ b/tests/Unit/Encoder/ObjectEncoderTest.php @@ -212,6 +212,50 @@ public function test_it_can_decode_from_xml_item(): void static::assertEquals($expected, $actual); } + public function test_it_decodes_absent_elements_as_null(): void + { + $encoder = new ObjectEncoder(stdClass::class); + $xsdType = XsdType::create('user') + ->withXmlNamespace("https://test") + ->withXmlNamespaceName('test') + ->withXmlTargetNodeName('user') + ->withMeta(static fn (TypeMeta $meta): TypeMeta => $meta->withIsQualified(true)); + $context = self::createContext($xsdType, self::buildTypes()); + + $actual = $encoder->iso($context)->from(Element::fromString('')); + + static::assertEquals( + (object)[ + 'active' => null, + 'hat' => (object)[ + 'color' => null, + ], + ], + $actual + ); + } + + public function test_it_decodes_absent_attributes_as_null(): void + { + $encoder = new ObjectEncoder(stdClass::class); + $xsdType = XsdType::create('user') + ->withXmlNamespace("https://test") + ->withXmlNamespaceName('test') + ->withXmlTargetNodeName('user') + ->withMeta(static fn (TypeMeta $meta): TypeMeta => $meta->withIsQualified(true)); + $context = self::createContext($xsdType, self::buildTypes(activeAsAttribute: true)); + + $actual = $encoder->iso($context)->from(Element::fromString('')); + + static::assertEquals( + (object)[ + 'active' => null, + 'hat' => null, + ], + $actual + ); + } + public function test_it_can_encode_from_array(): void { $encoder = new ObjectEncoder(stdClass::class); From 6dc325bd2337f6d40b4f702de2ca091ad87fea2b Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 17:12:17 +0200 Subject: [PATCH 5/7] Cover the encode and decode scenarios that throw internally One scenario per kind of intermediate throw, asserting today's output. Encoding a class with uninitialised typed properties had no coverage at all. The follow-up changes can add a no-throws assertion to the same scenarios. --- tests/Fixture/Model/Address.php | 11 + tests/Fixture/Model/Item.php | 17 ++ .../IntermediateThrowScenariosTest.php | 240 ++++++++++++++++++ 3 files changed, 268 insertions(+) create mode 100644 tests/Fixture/Model/Address.php create mode 100644 tests/Fixture/Model/Item.php create mode 100644 tests/Integration/IntermediateThrowScenariosTest.php diff --git a/tests/Fixture/Model/Address.php b/tests/Fixture/Model/Address.php new file mode 100644 index 0000000..ac42d33 --- /dev/null +++ b/tests/Fixture/Model/Address.php @@ -0,0 +1,11 @@ +|null */ + public ?array $tags; + public ?string $code; + public ?int $rank; +} diff --git a/tests/Integration/IntermediateThrowScenariosTest.php b/tests/Integration/IntermediateThrowScenariosTest.php new file mode 100644 index 0000000..6f1b0d2 --- /dev/null +++ b/tests/Integration/IntermediateThrowScenariosTest.php @@ -0,0 +1,240 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + XSD; + + /** + * @param Closure(): mixed $operation + */ + #[DataProvider('provideScenarios')] + public function test_it_encodes_and_decodes_the_scenario(Closure $operation, mixed $expected): void + { + static::assertEquals($expected, $operation()); + } + + /** + * @return iterable + */ + public static function provideScenarios(): iterable + { + $literal = self::driver('tns:itemType', 'literal'); + $typed = self::driver( + 'tns:itemType', + 'literal', + EncoderRegistry::default() + ->addClassMap('http://test-uri/', 'itemType', Item::class) + ->addClassMap('http://test-uri/', 'addressType', Address::class), + ); + + yield 'decode sparse response into stdClass' => [ + 'operation' => static fn (): mixed => $literal->decode( + 'test', + self::response('42
C
'), + ), + 'expected' => (object) [ + 'code' => null, + 'rank' => null, + 'id' => 42, + 'name' => null, + 'price' => null, + 'active' => null, + 'address' => (object) ['street' => null, 'city' => 'C', 'zip' => null], + 'tags' => [], + ], + ]; + + yield 'decode nil element into stdClass' => [ + 'operation' => static fn (): mixed => $literal->decode( + 'test', + self::response('42
'), + ), + 'expected' => (object) [ + 'code' => null, + 'rank' => null, + 'id' => 42, + 'name' => null, + 'price' => null, + 'active' => null, + 'address' => null, + 'tags' => [], + ], + ]; + + yield 'decode single occurrence of a repeating element' => [ + 'operation' => static fn (): mixed => $literal->decode( + 'test', + self::response('1a'), + ), + 'expected' => (object) [ + 'code' => null, + 'rank' => null, + 'id' => 1, + 'name' => null, + 'price' => null, + 'active' => null, + 'address' => null, + 'tags' => ['a'], + ], + ]; + + yield 'encode stdClass with absent properties' => [ + 'operation' => static fn (): string => $literal->encode('test', [(object) ['id' => 1]])->getRequest(), + 'expected' => self::literalRequest( + '1
' + ), + ]; + + yield 'encode sparse array input' => [ + 'operation' => static fn (): string => $literal->encode('test', [['id' => 1, 'address' => ['city' => 'C']]])->getRequest(), + 'expected' => self::literalRequest( + '1
C
' + ), + ]; + + yield 'encode uninitialised typed properties' => [ + 'operation' => static function () use ($typed): string { + $item = new Item(); + $item->id = 42; + + return $typed->encode('test', [$item])->getRequest(); + }, + 'expected' => self::literalRequest( + '42
' + ), + ]; + + $untyped = self::driver('tns:untypedValues', 'literal'); + yield 'decode untyped values' => [ + 'operation' => static fn (): mixed => $untyped->decode( + 'test', + self::response('51.5trueword'), + ), + 'expected' => (object) ['integer' => 5, 'float' => 1.5, 'boolean' => true, 'string' => 'word'], + ]; + + $soapEncArray = self::driver('tns:intArray', 'encoded'); + yield 'decode soap-enc array of a builtin type' => [ + 'operation' => static fn (): mixed => $soapEncArray->decode( + 'test', + self::response('12'), + ), + 'expected' => [1, 2], + ]; + } + + private static function driver(string $partType, string $use, ?EncoderRegistry $registry = null): Driver + { + $schema = self::ITEM_SCHEMA; + $encodingStyle = $use === 'encoded' ? ' encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"' : ''; + $wsdl = << + + + + + {$schema} + + + + + + + + + + + + + + + + + + + WSDL; + + $wsdlObject = (new Wsdl1Reader(new CallbackLoader(static fn (): string => $wsdl)))('file.wsdl'); + + return Driver::createFromMetadata( + (new Wsdl1MetadataProvider($wsdlObject))->getMetadata(), + $wsdlObject->namespaces, + $registry ?? EncoderRegistry::default(), + ); + } + + private static function response(string $testParam): SoapResponse + { + return new SoapResponse( + '' + .''.$testParam.'' + .'' + ); + } + + private static function literalRequest(string $testParam): string + { + return '' + .'' + .''.$testParam.'' + .''."\n"; + } +} From 3dd2a06206d8b0900b936c1d6f0f730608afac9b Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 17:15:09 +0200 Subject: [PATCH 6/7] Show which heuristic left throws out of the report Tests that catch an exception themselves are now counted apart from the ones using expectException(), so the effect of the catch heuristic is visible in the totals. The parser starts over when a new trace header appears before a call stack, and the extension refuses to bootstrap without Xdebug develop mode instead of failing inside a subscriber. --- tests/Support/Throws/AssertsNoThrows.php | 2 +- tests/Support/Throws/ThrowAttribution.php | 44 ++++++++++++------- .../Throws/ThrowAttributionExtension.php | 5 +++ .../Throws/XdebugExceptionTraceParser.php | 19 ++++++-- 4 files changed, 49 insertions(+), 21 deletions(-) diff --git a/tests/Support/Throws/AssertsNoThrows.php b/tests/Support/Throws/AssertsNoThrows.php index 55a3e02..452f8d2 100644 --- a/tests/Support/Throws/AssertsNoThrows.php +++ b/tests/Support/Throws/AssertsNoThrows.php @@ -33,7 +33,7 @@ protected static function assertNoThrows(callable $operation): void static::assertCount( 0, $events, - sprintf("Expected no exceptions to be thrown, got %d:\n%s", count($events), implode("\n", $lines)) + sprintf("Expected no exceptions to be thrown, got %d:\n%s", count($events), implode("\n", $lines)), ); } } diff --git a/tests/Support/Throws/ThrowAttribution.php b/tests/Support/Throws/ThrowAttribution.php index d7b7090..6921047 100644 --- a/tests/Support/Throws/ThrowAttribution.php +++ b/tests/Support/Throws/ThrowAttribution.php @@ -23,10 +23,13 @@ final class ThrowAttribution /** @var array|null */ private ?array $previousIni = null; private string $currentTest = ''; - private bool $currentExpectsException = false; + private ?string $currentExclusion = null; private int $all = 0; private int $outsideSrc = 0; - private int $inTestsExpectingExceptions = 0; + /** @var array{expectException: int, catch: int} */ + private array $excluded = ['expectException' => 0, 'catch' => 0]; + /** @var array> */ + private array $fileLines = []; /** @var array}> */ private array $sites = []; /** @var array */ @@ -37,7 +40,7 @@ final class ThrowAttribution public function testStarted(Test $test): void { $this->currentTest = $test->id(); - $this->currentExpectsException = $test instanceof TestMethod && self::expectsException($test); + $this->currentExclusion = $test instanceof TestMethod ? $this->exclusion($test) : null; $this->previousIni ??= ThrowCounter::enable(); } @@ -45,8 +48,8 @@ public function testPrinted(string $output): void { foreach (XdebugExceptionTraceParser::parse($output) as $event) { $this->all++; - if ($this->currentExpectsException) { - $this->inTestsExpectingExceptions++; + if ($this->currentExclusion !== null) { + $this->excluded[$this->currentExclusion]++; continue; } if ($event->srcCaller() === null) { @@ -88,13 +91,14 @@ public function render(): string $lines = [ sprintf( 'Intermediate throws: %d in %d tests', - $this->all - $this->outsideSrc - $this->inTestsExpectingExceptions, + $this->all - $this->outsideSrc - $this->excluded['expectException'] - $this->excluded['catch'], count($this->tests), ), sprintf( - 'Not counted: %d outside src/, %d in tests that expect an exception (%d throws in total)', + 'Not counted: %d outside src/, %d in tests using expectException(), %d in tests catching an exception themselves (%d throws in total)', $this->outsideSrc, - $this->inTestsExpectingExceptions, + $this->excluded['expectException'], + $this->excluded['catch'], $this->all, ), ]; @@ -112,23 +116,31 @@ public function render(): string /** * PHPUnit does not report expectException() as an event, so the test method body is checked instead. - * A test that catches one of the exceptions this package reports errors with counts as expecting one too. + * A test that catches one of the exceptions this package reports errors with is left out as well, + * and counted separately in the report so the effect of that heuristic stays visible. + * + * @return 'expectException'|'catch'|null */ - private static function expectsException(TestMethod $test): bool + private function exclusion(TestMethod $test): ?string { $method = new ReflectionMethod($test->className(), $test->methodName()); $file = $method->getFileName(); $start = $method->getStartLine(); $end = $method->getEndLine(); if ($file === false || $start === false || $end === false) { - return false; + return null; } - $body = implode('', array_slice(file($file) ?: [], $start - 1, $end - $start + 1)); + $this->fileLines[$file] ??= file($file) ?: []; + $body = implode('', array_slice($this->fileLines[$file], $start - 1, $end - $start + 1)); - return preg_match( - '/\bexpectException\w*\s*\(|\bcatch\s*\(\s*\\\\?(?:[\w\\\\]*\\\\)?(?:EncodingException|RestrictionException|SoapFaultException|Throwable|Exception)(?![\w\\\\])/', - $body - ) === 1; + return match (true) { + preg_match('/\bexpectException\w*\s*\(/', $body) === 1 => 'expectException', + preg_match( + '/\bcatch\s*\(\s*\\\\?(?:[\w\\\\]*\\\\)?(?:EncodingException|RestrictionException|SoapFaultException|Throwable|Exception)(?![\w\\\\])/', + $body, + ) === 1 => 'catch', + default => null, + }; } } diff --git a/tests/Support/Throws/ThrowAttributionExtension.php b/tests/Support/Throws/ThrowAttributionExtension.php index 35b364e..cd63ecd 100644 --- a/tests/Support/Throws/ThrowAttributionExtension.php +++ b/tests/Support/Throws/ThrowAttributionExtension.php @@ -21,6 +21,7 @@ use PHPUnit\Runner\Extension\Facade; use PHPUnit\Runner\Extension\ParameterCollection; use PHPUnit\TextUI\Configuration\Configuration; +use RuntimeException; /** * Prints the intermediate throws of a suite run per site, see composer throws:report. @@ -33,6 +34,10 @@ final class ThrowAttributionExtension implements Extension { public function bootstrap(Configuration $configuration, Facade $facade, ParameterCollection $parameters): void { + if (!ThrowCounter::isAvailable()) { + throw new RuntimeException('The throws report needs Xdebug with xdebug.mode=develop, run composer throws:report.'); + } + $attribution = new ThrowAttribution(); $facade->registerSubscribers( diff --git a/tests/Support/Throws/XdebugExceptionTraceParser.php b/tests/Support/Throws/XdebugExceptionTraceParser.php index e99da72..fb06ac1 100644 --- a/tests/Support/Throws/XdebugExceptionTraceParser.php +++ b/tests/Support/Throws/XdebugExceptionTraceParser.php @@ -37,11 +37,14 @@ public static function parse(string $output): array continue; } - $events[] = new ThrowEvent($event['class'], $event['message'], $event['file'], $event['line'], $event['frames']); + $events[] = self::toEvent($event); $event = null; } - if ($header !== null) { + $startsTrace = preg_match('/^([A-Za-z_\\\\][\w\\\\]*): /', $line, $match) === 1 + && is_a($match[1], Throwable::class, true); + + if ($header !== null && !$startsTrace) { if ($line === 'Call Stack:') { $event = self::parseHeader($header); $header = null; @@ -51,18 +54,26 @@ public static function parse(string $output): array continue; } - if (preg_match('/^([A-Za-z_\\\\][\w\\\\]*): /', $line, $match) && is_a($match[1], Throwable::class, true)) { + if ($startsTrace) { $header = $line; } } if ($event !== null) { - $events[] = new ThrowEvent($event['class'], $event['message'], $event['file'], $event['line'], $event['frames']); + $events[] = self::toEvent($event); } return $events; } + /** + * @param array{class: class-string, message: string, file: string, line: int, frames: list} $event + */ + private static function toEvent(array $event): ThrowEvent + { + return new ThrowEvent($event['class'], $event['message'], $event['file'], $event['line'], $event['frames']); + } + /** * @return array{class: class-string, message: string, file: string, line: int, frames: list}|null */ From 2cee7c74e4c586051bfcc37e9ba7b6db384c689f Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 08:52:00 +0200 Subject: [PATCH 7/7] Show the intermediate throw report on CI The report over the whole suite goes into the job log and the GitHub job summary, so every pull request shows where intermediate throws happen without running it locally. --- .github/workflows/throws.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/throws.yaml b/.github/workflows/throws.yaml index 336b9fe..4609137 100644 --- a/.github/workflows/throws.yaml +++ b/.github/workflows/throws.yaml @@ -24,3 +24,16 @@ jobs: run: composer update --prefer-dist --no-progress --no-suggest - name: Run the throw counting tests run: composer test:throws + - name: Report intermediate throws over the whole suite + run: | + status=0 + composer throws:report > throws-report.txt 2>&1 || status=$? + cat throws-report.txt + { + echo '## Intermediate throws' + echo + echo '```' + sed -n '/^Intermediate throws:/,$p' throws-report.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit $status