From 129eb26e65857144bceb7058dc983d6f96974f01 Mon Sep 17 00:00:00 2001 From: ruthbri Date: Mon, 31 Aug 2026 15:50:49 -0300 Subject: [PATCH 1/4] class JUnitReport #60 --- src/JUnitReport.php | 410 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 src/JUnitReport.php diff --git a/src/JUnitReport.php b/src/JUnitReport.php new file mode 100644 index 0000000..7c60f95 --- /dev/null +++ b/src/JUnitReport.php @@ -0,0 +1,410 @@ +reports = $reports; + // If execution_time is not provided, calculate it as a rough estimate + $this->execution_time = $execution_time !== null ? $execution_time : $this->estimate_execution_time($reports); + } + + /** + * Estimate total execution time based on number of tests + * Rough estimate: 0.1 seconds per test + */ + private function estimate_execution_time($reports) + { + $test_count = 0; + foreach ($reports as $suite_reports) { + foreach ($suite_reports as $test_case_data) { + $test_count += count($test_case_data); + } + } + return $test_count * 0.1; + } + + /** + * Generate JUnit XML from reports + * + * @param string $output_path Path where to save the XML file + * @return string Path to generated file + */ + public function generate($output_path) + { + // Create XML structure + $this->doc = new DOMDocument('1.0', 'UTF-8'); + $this->doc->formatOutput = true; + + // Group reports by suite + $suites_data = $this->group_by_suite(); + + // Create root element + $total_tests = 0; + $total_failures = 0; + $total_errors = 0; + $total_skipped = 0; + + // Calculate totals + foreach ($suites_data as $suite_tests) + { + foreach ($suite_tests as $test_data) + { + $total_tests++; + $total_failures += $test_data['failures']; + $total_errors += $test_data['errors']; + $total_skipped += $test_data['skipped']; + } + } + + // Create root testsuites element + $this->root_suite = $this->doc->createElement('testsuites'); + $this->root_suite->setAttribute('name', self::ROOT_REPORT_NAME); + $this->root_suite->setAttribute('tests', $total_tests); + $this->root_suite->setAttribute('failures', $total_failures); + $this->root_suite->setAttribute('errors', $total_errors); + $this->root_suite->setAttribute('skipped', $total_skipped); + $this->root_suite->setAttribute('time', number_format($this->execution_time, 3)); + + $this->doc->appendChild($this->root_suite); + + // Create testsuite elements for each suite folder + foreach ($suites_data as $suite_name => $suite_tests) + { + $this->create_testsuite($suite_name, $suite_tests); + } + + // Create output directory if it doesn't exist + $dir = dirname($output_path); + if (!is_dir($dir)) + { + mkdir($dir, 0755, true); + } + + // Save XML + $this->doc->save($output_path); + + return $output_path; + } + + /** + * Group reports by suite folder name + * Structure: suite_folder => [test_function => test_data, ...] + */ + private function group_by_suite() + { + $grouped = []; + + // Handle both array and potentially keyed arrays + // $this->reports is an array where each element is a suite's test results + // Each suite result is: [test_case_class => [test_function => [asserts => [...], output => '...'], ...], ...] + + foreach ($this->reports as $suite_reports) + { + // Skip empty suites + if (empty($suite_reports)) + { + continue; + } + + // Determine suite name from first test case class in this suite + $suite_name = null; + + // Each suite contains test cases + foreach ($suite_reports as $test_case_class => $test_case_data) + { + // Extract suite name from class namespace (only once per suite) + if ($suite_name === null) + { + $suite_name = $this->extract_suite_name($test_case_class); + $grouped[$suite_name] = []; + } + + // Each test case class contains test functions + foreach ($test_case_data as $test_function => $test_data) + { + $test_key = $this->extract_class_name($test_case_class) . '::' . $test_function; + + $test_info = [ + 'function' => $test_function, + 'class' => $test_case_class, + 'suite' => $suite_name, + 'data' => $test_data, + 'failures' => 0, + 'errors' => 0, + 'skipped' => 0, + 'assertions' => 0, + 'time' => 0.1 // Default time + ]; + + // Analyze test results + if (isset($test_data['asserts'])) + { + $test_info['assertions'] = count($test_data['asserts']); + + foreach ($test_data['asserts'] as $assert) + { + if (isset($assert['type'])) + { + if ($assert['type'] === 'FAIL') + { + $test_info['failures']++; + } + elseif ($assert['type'] === 'ERROR' || $assert['type'] === 'EXCEPTION') + { + $test_info['errors']++; + } + } + } + } + + $grouped[$suite_name][$test_key] = $test_info; + } + } + } + + ksort($grouped); + return $grouped; + } + + /** + * Extract suite name from fully qualified class name + */ + private function extract_suite_name($class_name) + { + // E.g., "tests\report_claim_per_org\TestReportClaimPerOrg" => "report_claim_per_org" + $parts = explode('\\', $class_name); + + // Remove 'tests' namespace and class name, keep the folder + if (count($parts) >= 2) + { + // Return the folder part (usually at index 1) + return $parts[1]; + } + + return 'default'; + } + + /** + * Create a testsuite XML element + */ + private function create_testsuite($suite_name, $tests) + { + $total_tests = count($tests); + $total_failures = 0; + $total_errors = 0; + $total_skipped = 0; + $total_time = 0; + $total_assertions = 0; + + // Calculate totals for this suite + foreach ($tests as $test_data) + { + $total_failures += $test_data['failures']; + $total_errors += $test_data['errors']; + $total_skipped += $test_data['skipped']; + $total_time += $test_data['time']; + $total_assertions += $test_data['assertions']; + } + + $testsuite = $this->doc->createElement('testsuite'); + $testsuite->setAttribute('name', 'suite: '. $this->format_suite_name($suite_name)); + $testsuite->setAttribute('tests', $total_tests); + $testsuite->setAttribute('failures', $total_failures); + $testsuite->setAttribute('errors', $total_errors); + $testsuite->setAttribute('skipped', $total_skipped); + $testsuite->setAttribute('assertions', $total_assertions); + $testsuite->setAttribute('time', number_format($total_time, 3)); + + // Add timestamp + $now = new DateTime('now', new DateTimeZone('UTC')); + $testsuite->setAttribute('timestamp', $now->format('c')); + + // Add properties + $this->add_properties($testsuite); + + // Add test cases + foreach ($tests as $test_data) + { + $this->create_testcase($testsuite, $test_data); + } + + $this->root_suite->appendChild($testsuite); + } + + /** + * Format suite folder names to readable titles. + */ + private function format_suite_name($suite_name) + { + return str_replace('_', ' ', $suite_name); + } + + /** + * Add system properties to testsuite + */ + private function add_properties($testsuite_element) + { + $properties = $this->doc->createElement('properties'); + + $php_version = phpversion(); + $property_php = $this->doc->createElement('property'); + $property_php->setAttribute('name', 'php.version'); + $property_php->setAttribute('value', $php_version); + $properties->appendChild($property_php); + + $property_os = $this->doc->createElement('property'); + $property_os->setAttribute('name', 'os'); + $property_os->setAttribute('value', php_uname('s')); + $properties->appendChild($property_os); + + $testsuite_element->appendChild($properties); + } + + /** + * Create a testcase XML element + */ + private function create_testcase($testsuite, $test_data) + { + $testcase = $this->doc->createElement('testcase'); + + // GitHub renders titles as "name (classname)". + $testcase->setAttribute('name', $test_data['suite']); + $testcase->setAttribute('classname', $this->extract_class_name($test_data['class'])); + $testcase->setAttribute('assertions', $test_data['assertions']); + $testcase->setAttribute('time', number_format($test_data['time'], 3)); + + // Add failure or error elements + if (isset($test_data['data']['asserts'])) + { + foreach ($test_data['data']['asserts'] as $assert) + { + if ($assert['type'] === 'FAIL') + { + $failure = $this->doc->createElement('failure'); + $failure->setAttribute('type', 'AssertionFailedError'); + $failure->setAttribute('message', $this->build_assert_message($test_data['function'], $assert['msg'] ?? 'Assertion failed')); + + $failure_text = $this->doc->createCDATASection($this->format_assert_details($assert)); + $failure->appendChild($failure_text); + + $testcase->appendChild($failure); + } + elseif ($assert['type'] === 'ERROR' || $assert['type'] === 'EXCEPTION') + { + $error = $this->doc->createElement('error'); + $error->setAttribute('type', $assert['type']); + $error->setAttribute('message', $this->build_assert_message($test_data['function'], $assert['msg'] ?? 'Error occurred')); + + $error_text = $this->doc->createCDATASection($this->format_exception_details($assert)); + $error->appendChild($error_text); + + $testcase->appendChild($error); + } + } + } + + // Add output if exists + if (isset($test_data['data']['output']) && !empty($test_data['data']['output'])) + { + $system_out = $this->doc->createElement('system-out'); + $system_out->appendChild($this->doc->createCDATASection($test_data['data']['output'])); + $testcase->appendChild($system_out); + } + + $testsuite->appendChild($testcase); + } + + /** + * Format assertion details for output + */ + private function format_assert_details($assert) + { + $details = "Assertion: " . ($assert['msg'] ?? 'Failed') . "\n"; + + if (!empty($assert['params'])) + { + $details .= "\nParameters:\n" . $assert['params']; + } + + if (!empty($assert['trace'])) + { + $details .= "\nStack Trace:\n"; + foreach ($assert['trace'] as $frame) + { + $file = $frame['file'] ?? 'unknown'; + $line = $frame['line'] ?? '?'; + $function = $frame['function'] ?? 'unknown'; + $details .= " at $function() in $file:$line\n"; + } + } + + return $details; + } + + /** + * Format exception details for output + */ + private function format_exception_details($exception) + { + $details = "Exception: " . ($exception['msg'] ?? 'Unknown error') . "\n"; + + if (!empty($exception['trace'])) + { + $details .= "\nStack Trace:\n"; + foreach ($exception['trace'] as $frame) + { + $file = $frame['file'] ?? 'unknown'; + $line = $frame['line'] ?? '?'; + $function = $frame['function'] ?? 'unknown'; + $class = $frame['class'] ?? ''; + + if ($class) + { + $details .= " #0 $file($line): $class::$function()\n"; + } + else + { + $details .= " #0 $file($line): $function()\n"; + } + } + } + + return $details; + } + + /** + * Extract class name from fully qualified name + */ + private function extract_class_name($class_name) + { + $parts = explode('\\', $class_name); + return end($parts); + } + + /** + * Prefix assert messages with test function name to preserve detail in annotations. + */ + private function build_assert_message($function_name, $message) + { + return '['. $function_name .'] '. $message; + } +} \ No newline at end of file From c12dcce88d995e6e62f142d33d9710d7b6f346dc Mon Sep 17 00:00:00 2001 From: ruthbri Date: Wed, 2 Sep 2026 18:02:33 -0300 Subject: [PATCH 2/4] XmlBuilder #60 --- .github/workflows/test.yml | 2 +- cli.php | 26 ++- src/JUnitReport.php | 410 ------------------------------------- src/JunitXmlBuilder.php | 349 +++++++++++++++++++++++++------ test-results.xml | 340 ++++++++++++++++++++++-------- 5 files changed, 560 insertions(+), 567 deletions(-) delete mode 100644 src/JUnitReport.php diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ea4f603..d5c20a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '8.3' tools: composer - name: Install dependencies diff --git a/cli.php b/cli.php index 7e3b5ea..939921e 100644 --- a/cli.php +++ b/cli.php @@ -13,12 +13,15 @@ * argv[3] -> case (optional) * argv[4] -> -report=html (optional) * argv[5] -> -output="./output/out.html" (optional), if no path is specified it will be saved in the root + * JUnit example -> php cli.php tests -report=junit reports/test-results.xml * */ $output = '.'. DIRECTORY_SEPARATOR; $report = 'html'; $output_index = 0; $report_index = 0; +$junit_output = 'test-results.xml'; +$junit_output_index = 0; $test_start_time = 0; $test_end_time = 0; $test_time = 0; @@ -33,6 +36,13 @@ $report = end($type_out); $argc--; $report_index = $i; + + if ($report == 'junit' && isset($argv[$i + 1]) && substr($argv[$i + 1], 0, 1) != '-') + { + $junit_output = $argv[$i + 1]; + $junit_output_index = $i + 1; + $argc--; + } } if ($search == '-output') @@ -47,6 +57,7 @@ // remove output and report parameters, so the rest are in the same order for the execution (suite, class, function) if ($output_index > 0) unset($argv[$output_index]); if ($report_index > 0) unset($argv[$report_index]); +if ($junit_output_index > 0) unset($argv[$junit_output_index]); $argv = array_values($argv); if ($argc < 2) @@ -105,7 +116,20 @@ } else if ($report == 'junit') { - $exit_code = $run->generate_junit_xml(); + $builder = new \CaboLabs\Debbie\JunitXmlBuilder(); + $generated = $builder->generateFromReports( + $run->get_reports(), + $junit_output + ); + + if (!$generated) + { + $exit_code = 1; + } + else + { + $exit_code = $builder->hasFailures() ? 1 : 0; + } } else { diff --git a/src/JUnitReport.php b/src/JUnitReport.php deleted file mode 100644 index 7c60f95..0000000 --- a/src/JUnitReport.php +++ /dev/null @@ -1,410 +0,0 @@ -reports = $reports; - // If execution_time is not provided, calculate it as a rough estimate - $this->execution_time = $execution_time !== null ? $execution_time : $this->estimate_execution_time($reports); - } - - /** - * Estimate total execution time based on number of tests - * Rough estimate: 0.1 seconds per test - */ - private function estimate_execution_time($reports) - { - $test_count = 0; - foreach ($reports as $suite_reports) { - foreach ($suite_reports as $test_case_data) { - $test_count += count($test_case_data); - } - } - return $test_count * 0.1; - } - - /** - * Generate JUnit XML from reports - * - * @param string $output_path Path where to save the XML file - * @return string Path to generated file - */ - public function generate($output_path) - { - // Create XML structure - $this->doc = new DOMDocument('1.0', 'UTF-8'); - $this->doc->formatOutput = true; - - // Group reports by suite - $suites_data = $this->group_by_suite(); - - // Create root element - $total_tests = 0; - $total_failures = 0; - $total_errors = 0; - $total_skipped = 0; - - // Calculate totals - foreach ($suites_data as $suite_tests) - { - foreach ($suite_tests as $test_data) - { - $total_tests++; - $total_failures += $test_data['failures']; - $total_errors += $test_data['errors']; - $total_skipped += $test_data['skipped']; - } - } - - // Create root testsuites element - $this->root_suite = $this->doc->createElement('testsuites'); - $this->root_suite->setAttribute('name', self::ROOT_REPORT_NAME); - $this->root_suite->setAttribute('tests', $total_tests); - $this->root_suite->setAttribute('failures', $total_failures); - $this->root_suite->setAttribute('errors', $total_errors); - $this->root_suite->setAttribute('skipped', $total_skipped); - $this->root_suite->setAttribute('time', number_format($this->execution_time, 3)); - - $this->doc->appendChild($this->root_suite); - - // Create testsuite elements for each suite folder - foreach ($suites_data as $suite_name => $suite_tests) - { - $this->create_testsuite($suite_name, $suite_tests); - } - - // Create output directory if it doesn't exist - $dir = dirname($output_path); - if (!is_dir($dir)) - { - mkdir($dir, 0755, true); - } - - // Save XML - $this->doc->save($output_path); - - return $output_path; - } - - /** - * Group reports by suite folder name - * Structure: suite_folder => [test_function => test_data, ...] - */ - private function group_by_suite() - { - $grouped = []; - - // Handle both array and potentially keyed arrays - // $this->reports is an array where each element is a suite's test results - // Each suite result is: [test_case_class => [test_function => [asserts => [...], output => '...'], ...], ...] - - foreach ($this->reports as $suite_reports) - { - // Skip empty suites - if (empty($suite_reports)) - { - continue; - } - - // Determine suite name from first test case class in this suite - $suite_name = null; - - // Each suite contains test cases - foreach ($suite_reports as $test_case_class => $test_case_data) - { - // Extract suite name from class namespace (only once per suite) - if ($suite_name === null) - { - $suite_name = $this->extract_suite_name($test_case_class); - $grouped[$suite_name] = []; - } - - // Each test case class contains test functions - foreach ($test_case_data as $test_function => $test_data) - { - $test_key = $this->extract_class_name($test_case_class) . '::' . $test_function; - - $test_info = [ - 'function' => $test_function, - 'class' => $test_case_class, - 'suite' => $suite_name, - 'data' => $test_data, - 'failures' => 0, - 'errors' => 0, - 'skipped' => 0, - 'assertions' => 0, - 'time' => 0.1 // Default time - ]; - - // Analyze test results - if (isset($test_data['asserts'])) - { - $test_info['assertions'] = count($test_data['asserts']); - - foreach ($test_data['asserts'] as $assert) - { - if (isset($assert['type'])) - { - if ($assert['type'] === 'FAIL') - { - $test_info['failures']++; - } - elseif ($assert['type'] === 'ERROR' || $assert['type'] === 'EXCEPTION') - { - $test_info['errors']++; - } - } - } - } - - $grouped[$suite_name][$test_key] = $test_info; - } - } - } - - ksort($grouped); - return $grouped; - } - - /** - * Extract suite name from fully qualified class name - */ - private function extract_suite_name($class_name) - { - // E.g., "tests\report_claim_per_org\TestReportClaimPerOrg" => "report_claim_per_org" - $parts = explode('\\', $class_name); - - // Remove 'tests' namespace and class name, keep the folder - if (count($parts) >= 2) - { - // Return the folder part (usually at index 1) - return $parts[1]; - } - - return 'default'; - } - - /** - * Create a testsuite XML element - */ - private function create_testsuite($suite_name, $tests) - { - $total_tests = count($tests); - $total_failures = 0; - $total_errors = 0; - $total_skipped = 0; - $total_time = 0; - $total_assertions = 0; - - // Calculate totals for this suite - foreach ($tests as $test_data) - { - $total_failures += $test_data['failures']; - $total_errors += $test_data['errors']; - $total_skipped += $test_data['skipped']; - $total_time += $test_data['time']; - $total_assertions += $test_data['assertions']; - } - - $testsuite = $this->doc->createElement('testsuite'); - $testsuite->setAttribute('name', 'suite: '. $this->format_suite_name($suite_name)); - $testsuite->setAttribute('tests', $total_tests); - $testsuite->setAttribute('failures', $total_failures); - $testsuite->setAttribute('errors', $total_errors); - $testsuite->setAttribute('skipped', $total_skipped); - $testsuite->setAttribute('assertions', $total_assertions); - $testsuite->setAttribute('time', number_format($total_time, 3)); - - // Add timestamp - $now = new DateTime('now', new DateTimeZone('UTC')); - $testsuite->setAttribute('timestamp', $now->format('c')); - - // Add properties - $this->add_properties($testsuite); - - // Add test cases - foreach ($tests as $test_data) - { - $this->create_testcase($testsuite, $test_data); - } - - $this->root_suite->appendChild($testsuite); - } - - /** - * Format suite folder names to readable titles. - */ - private function format_suite_name($suite_name) - { - return str_replace('_', ' ', $suite_name); - } - - /** - * Add system properties to testsuite - */ - private function add_properties($testsuite_element) - { - $properties = $this->doc->createElement('properties'); - - $php_version = phpversion(); - $property_php = $this->doc->createElement('property'); - $property_php->setAttribute('name', 'php.version'); - $property_php->setAttribute('value', $php_version); - $properties->appendChild($property_php); - - $property_os = $this->doc->createElement('property'); - $property_os->setAttribute('name', 'os'); - $property_os->setAttribute('value', php_uname('s')); - $properties->appendChild($property_os); - - $testsuite_element->appendChild($properties); - } - - /** - * Create a testcase XML element - */ - private function create_testcase($testsuite, $test_data) - { - $testcase = $this->doc->createElement('testcase'); - - // GitHub renders titles as "name (classname)". - $testcase->setAttribute('name', $test_data['suite']); - $testcase->setAttribute('classname', $this->extract_class_name($test_data['class'])); - $testcase->setAttribute('assertions', $test_data['assertions']); - $testcase->setAttribute('time', number_format($test_data['time'], 3)); - - // Add failure or error elements - if (isset($test_data['data']['asserts'])) - { - foreach ($test_data['data']['asserts'] as $assert) - { - if ($assert['type'] === 'FAIL') - { - $failure = $this->doc->createElement('failure'); - $failure->setAttribute('type', 'AssertionFailedError'); - $failure->setAttribute('message', $this->build_assert_message($test_data['function'], $assert['msg'] ?? 'Assertion failed')); - - $failure_text = $this->doc->createCDATASection($this->format_assert_details($assert)); - $failure->appendChild($failure_text); - - $testcase->appendChild($failure); - } - elseif ($assert['type'] === 'ERROR' || $assert['type'] === 'EXCEPTION') - { - $error = $this->doc->createElement('error'); - $error->setAttribute('type', $assert['type']); - $error->setAttribute('message', $this->build_assert_message($test_data['function'], $assert['msg'] ?? 'Error occurred')); - - $error_text = $this->doc->createCDATASection($this->format_exception_details($assert)); - $error->appendChild($error_text); - - $testcase->appendChild($error); - } - } - } - - // Add output if exists - if (isset($test_data['data']['output']) && !empty($test_data['data']['output'])) - { - $system_out = $this->doc->createElement('system-out'); - $system_out->appendChild($this->doc->createCDATASection($test_data['data']['output'])); - $testcase->appendChild($system_out); - } - - $testsuite->appendChild($testcase); - } - - /** - * Format assertion details for output - */ - private function format_assert_details($assert) - { - $details = "Assertion: " . ($assert['msg'] ?? 'Failed') . "\n"; - - if (!empty($assert['params'])) - { - $details .= "\nParameters:\n" . $assert['params']; - } - - if (!empty($assert['trace'])) - { - $details .= "\nStack Trace:\n"; - foreach ($assert['trace'] as $frame) - { - $file = $frame['file'] ?? 'unknown'; - $line = $frame['line'] ?? '?'; - $function = $frame['function'] ?? 'unknown'; - $details .= " at $function() in $file:$line\n"; - } - } - - return $details; - } - - /** - * Format exception details for output - */ - private function format_exception_details($exception) - { - $details = "Exception: " . ($exception['msg'] ?? 'Unknown error') . "\n"; - - if (!empty($exception['trace'])) - { - $details .= "\nStack Trace:\n"; - foreach ($exception['trace'] as $frame) - { - $file = $frame['file'] ?? 'unknown'; - $line = $frame['line'] ?? '?'; - $function = $frame['function'] ?? 'unknown'; - $class = $frame['class'] ?? ''; - - if ($class) - { - $details .= " #0 $file($line): $class::$function()\n"; - } - else - { - $details .= " #0 $file($line): $function()\n"; - } - } - } - - return $details; - } - - /** - * Extract class name from fully qualified name - */ - private function extract_class_name($class_name) - { - $parts = explode('\\', $class_name); - return end($parts); - } - - /** - * Prefix assert messages with test function name to preserve detail in annotations. - */ - private function build_assert_message($function_name, $message) - { - return '['. $function_name .'] '. $message; - } -} \ No newline at end of file diff --git a/src/JunitXmlBuilder.php b/src/JunitXmlBuilder.php index 7b71bb6..1592aff 100644 --- a/src/JunitXmlBuilder.php +++ b/src/JunitXmlBuilder.php @@ -5,19 +5,15 @@ /** * Builder class for creating JUnit XML test result files */ -class JUnitXMLBuilder +class JunitXmlBuilder { - private $testSuites = []; - private $startTime; + const ROOT_REPORT_NAME = 'Amplify - Tests Report'; - public function __construct() - { - $this->startTime = microtime(true); - } + private $testSuites = []; /** * Create a new test suite - * + * * @param string $name The name of the test suite (typically the test class name) * @return TestSuite */ @@ -28,18 +24,135 @@ public function addTestSuite($name) return $suite; } + /** + * * + * Historical context: + * before this method existed, DebbieRun transformed + * the reports manually before passing them to the builder. Now this + * function centralizes that conversion in JunitXmlBuilder, allowing + * DebbieRun to provide its original reports without losing information. + * Previously, DebbieRun::generate_junit_xml() only used part of the + * available information: it kept the first failure or error, ignored OK + * assertions, could discard additional failures, and did not always pass + * all error details and traces. This function now preserves all received + * assertions, failures, errors, exceptions, parameters, traces, and test + * output when converting the reports. + * + * Translate DebbieRun reports into the builder's internal format. + * + * This method converts the complete reports returned by DebbieRun into + * TestSuite and TestCase objects, without changing or duplicating report + * transformation logic in DebbieRun itself. + * + * @param array $reports Reports returned by DebbieRun::get_reports() + * @return self + */ + public function addReports(array $reports) + { + foreach ($reports as $suiteReports) { + foreach ($suiteReports as $className => $tests) { + $suite = $this->addTestSuite($className); + + foreach ($tests as $testName => $report) { + $testCase = $suite->addTestCase($testName); + + foreach (isset($report['asserts']) ? $report['asserts'] : [] as $assert) { + $type = isset($assert['type']) ? $assert['type'] : ''; + $message = $this->buildAssertionMessage( + $testName, + isset($assert['msg']) ? $assert['msg'] : '' + ); + + if ($type === 'OK') { + $testCase->addAssertion(); + } elseif ($type === 'FAIL') { + $testCase->addFailure($message, 'AssertionFailedError', $assert); + } elseif ($type === 'ERROR' || $type === 'EXCEPTION') { + $errorType = $type === 'EXCEPTION' ? 'Exception' : 'RuntimeError'; + $testCase->addError($message, $errorType, $assert); + } elseif ($type === 'SKIPPED') { + $testCase->setSkipped($message ?: 'Test skipped'); + } + } + + if (!empty($report['output'])) { + $testCase->addSystemOut($report['output']); + } + + $testCase->finish(); + } + } + } + + return $this; + } + + /** + * Prefix an assertion message with its test function name. + * + * @param string $testName Test function name + * @param string $message Assertion message + * @return string + */ + private function buildAssertionMessage($testName, $message) + { + return '[' . $testName . '] ' . $message; + } + + /** + * Clear suites previously added to the builder. + * + * @return self + */ + public function reset() + { + $this->testSuites = []; + return $this; + } + + /** + * Convert DebbieRun reports and save the resulting JUnit XML. + * + * @param array $reports Reports returned by DebbieRun::get_reports() + * @param string $filepath Path where to save the XML file + * @return bool True on success, false on failure + */ + public function generateFromReports(array $reports, $filepath) + { + $this->reset(); + $this->addReports($reports); + return $this->saveToFile($filepath); + } + + /** + * Check whether a failure or error was recorded. + * + * @return bool + */ + public function hasFailures() + { + foreach ($this->testSuites as $suite) { + $stats = $suite->getStatistics(); + if ($stats['failures'] > 0 || $stats['errors'] > 0) { + return true; + } + } + + return false; + } + /** * Generate the JUnit XML string - * + * * @return string The complete JUnit XML */ public function toXML() { - $totalTime = microtime(true) - $this->startTime; $totalTests = 0; $totalFailures = 0; $totalErrors = 0; $totalSkipped = 0; + $totalTime = 0; // Calculate totals foreach ($this->testSuites as $suite) { @@ -48,12 +161,14 @@ public function toXML() $totalFailures += $stats['failures']; $totalErrors += $stats['errors']; $totalSkipped += $stats['skipped']; + $totalTime += $stats['time']; } $xml = new \DOMDocument('1.0', 'UTF-8'); $xml->formatOutput = true; $testsuites = $xml->createElement('testsuites'); + $testsuites->setAttribute('name', self::ROOT_REPORT_NAME); $testsuites->setAttribute('tests', $totalTests); $testsuites->setAttribute('failures', $totalFailures); $testsuites->setAttribute('errors', $totalErrors); @@ -72,13 +187,17 @@ public function toXML() /** * Save the JUnit XML to a file - * + * * @param string $filepath Path to save the XML file * @return bool True on success, false on failure */ public function saveToFile($filepath) { $xml = $this->toXML(); + $directory = dirname($filepath); + if (!is_dir($directory) && !mkdir($directory, 0755, true)) { + return false; + } return file_put_contents($filepath, $xml) !== false; } } @@ -90,17 +209,20 @@ class TestSuite { private $name; private $testCases = []; - private $startTime; + /** + * Create a test suite. + * + * @param string $name Test suite name + */ public function __construct($name) { $this->name = $name; - $this->startTime = microtime(true); } /** * Add a test case to this suite - * + * * @param string $name The test case name (typically the test method name) * @return TestCase */ @@ -113,7 +235,7 @@ public function addTestCase($name) /** * Get statistics for this test suite - * + * * @return array Array with keys: tests, failures, errors, skipped */ public function getStatistics() @@ -123,6 +245,7 @@ public function getStatistics() 'failures' => 0, 'errors' => 0, 'skipped' => 0, + 'assertions' => 0, 'time' => 0 ]; @@ -136,6 +259,7 @@ public function getStatistics() if ($testCase->isSkipped()) { $stats['skipped']++; } + $stats['assertions'] += $testCase->getAssertionCount(); $stats['time'] += $testCase->getTime(); } @@ -144,21 +268,34 @@ public function getStatistics() /** * Convert this test suite to a DOMElement - * + * * @param \DOMDocument $doc The document to create elements in * @return \DOMElement */ public function toXMLElement(\DOMDocument $doc) { $stats = $this->getStatistics(); - + $element = $doc->createElement('testsuite'); $element->setAttribute('name', $this->name); $element->setAttribute('tests', $stats['tests']); $element->setAttribute('failures', $stats['failures']); $element->setAttribute('errors', $stats['errors']); $element->setAttribute('skipped', $stats['skipped']); + $element->setAttribute('assertions', $stats['assertions']); $element->setAttribute('time', number_format($stats['time'], 6, '.', '')); + $element->setAttribute('timestamp', gmdate('c')); + + $properties = $doc->createElement('properties'); + $phpProperty = $doc->createElement('property'); + $phpProperty->setAttribute('name', 'php.version'); + $phpProperty->setAttribute('value', phpversion()); + $properties->appendChild($phpProperty); + $osProperty = $doc->createElement('property'); + $osProperty->setAttribute('name', 'os'); + $osProperty->setAttribute('value', php_uname('s')); + $properties->appendChild($osProperty); + $element->appendChild($properties); foreach ($this->testCases as $testCase) { $testCaseElement = $testCase->toXMLElement($doc); @@ -177,13 +314,19 @@ class TestCase private $name; private $classname; private $time; - private $failure = null; - private $error = null; + private $failures = []; + private $errors = []; private $skipped = false; private $startTime; private $systemOut = ''; - private $systemErr = ''; + private $assertions = 0; + /** + * Create a test case. + * + * @param string $name Test case name + * @param string $classname Test class name + */ public function __construct($name, $classname) { $this->name = $name; @@ -197,12 +340,15 @@ public function __construct($name, $classname) */ public function finish() { - $this->time = microtime(true) - $this->startTime; + if ($this->time === 0) { + $this->time = microtime(true) - $this->startTime; + } + return $this; } /** * Add a failure to this test case - * + * * @param string $message The failure message * @param string $type The failure type (default: 'AssertionFailure') * @param string $details Additional details/stack trace @@ -210,7 +356,8 @@ public function finish() */ public function addFailure($message, $type = 'AssertionFailure', $details = '') { - $this->failure = [ + $this->assertions++; + $this->failures[] = [ 'message' => $message, 'type' => $type, 'details' => $details @@ -220,7 +367,7 @@ public function addFailure($message, $type = 'AssertionFailure', $details = '') /** * Add an error to this test case - * + * * @param string $message The error message * @param string $type The error type (default: 'RuntimeError') * @param string $details Additional details/stack trace @@ -228,7 +375,8 @@ public function addFailure($message, $type = 'AssertionFailure', $details = '') */ public function addError($message, $type = 'RuntimeError', $details = '') { - $this->error = [ + $this->assertions++; + $this->errors[] = [ 'message' => $message, 'type' => $type, 'details' => $details @@ -236,9 +384,20 @@ public function addError($message, $type = 'RuntimeError', $details = '') return $this; } + /** + * Record a successful assertion. + * + * @return self + */ + public function addAssertion() + { + $this->assertions++; + return $this; + } + /** * Mark this test case as skipped - * + * * @param string $message Optional skip message * @return self */ @@ -250,7 +409,7 @@ public function setSkipped($message = 'Test skipped') /** * Add standard output to this test case - * + * * @param string $output The output text * @return self */ @@ -260,41 +419,29 @@ public function addSystemOut($output) return $this; } - /** - * Add error output to this test case - * - * @param string $output The error output text - * @return self - */ - public function addSystemErr($output) - { - $this->systemErr .= $output; - return $this; - } - /** * Check if this test case has a failure - * + * * @return bool */ public function hasFailure() { - return $this->failure !== null; + return !empty($this->failures); } /** * Check if this test case has an error - * + * * @return bool */ public function hasError() { - return $this->error !== null; + return !empty($this->errors); } /** * Check if this test case is skipped - * + * * @return bool */ public function isSkipped() @@ -304,45 +451,59 @@ public function isSkipped() /** * Get the execution time - * + * * @return float */ public function getTime() { + $this->finish(); return $this->time; } + /** + * Get the number of recorded assertion results. + * + * @return int + */ + public function getAssertionCount() + { + return $this->assertions; + } + /** * Convert this test case to a DOMElement - * + * * @param \DOMDocument $doc The document to create elements in * @return \DOMElement */ public function toXMLElement(\DOMDocument $doc) { + $this->finish(); + $element = $doc->createElement('testcase'); $element->setAttribute('name', $this->name); $element->setAttribute('classname', $this->classname); + $element->setAttribute('assertions', $this->getAssertionCount()); $element->setAttribute('time', number_format($this->time, 6, '.', '')); - // Add failure element if present - if ($this->failure !== null) { + // Add every failure recorded for this test case. + foreach ($this->failures as $failure) { $failureElement = $doc->createElement('failure'); - $failureElement->setAttribute('message', $this->failure['message']); - $failureElement->setAttribute('type', $this->failure['type']); - if (!empty($this->failure['details'])) { - $failureElement->appendChild($doc->createTextNode($this->failure['details'])); + $failureElement->setAttribute('message', $failure['message']); + $failureElement->setAttribute('type', $failure['type']); + if (!empty($failure['details'])) { + $failureElement->appendChild($this->createSafeCDATA($doc, $this->formatDetails($failure['details']))); } $element->appendChild($failureElement); } - // Add error element if present - if ($this->error !== null) { + // Add every error recorded for this test case. + foreach ($this->errors as $error) { $errorElement = $doc->createElement('error'); - $errorElement->setAttribute('message', $this->error['message']); - $errorElement->setAttribute('type', $this->error['type']); - if (!empty($this->error['details'])) { - $errorElement->appendChild($doc->createTextNode($this->error['details'])); + $errorElement->setAttribute('message', $error['message']); + $errorElement->setAttribute('type', $error['type']); + if (!empty($error['details'])) { + $errorElement->appendChild($this->createSafeCDATA($doc, $this->formatDetails($error['details']))); } $element->appendChild($errorElement); } @@ -357,17 +518,75 @@ public function toXMLElement(\DOMDocument $doc) // Add system-out if present if (!empty($this->systemOut)) { $systemOutElement = $doc->createElement('system-out'); - $systemOutElement->appendChild($doc->createCDATASection($this->systemOut)); + $systemOutElement->appendChild($this->createSafeCDATA($doc, $this->systemOut)); $element->appendChild($systemOutElement); } - // Add system-err if present - if (!empty($this->systemErr)) { - $systemErrElement = $doc->createElement('system-err'); - $systemErrElement->appendChild($doc->createCDATASection($this->systemErr)); - $element->appendChild($systemErrElement); + return $element; + } + + /** + * Create a CDATA fragment that remains valid when content contains ]]>. + * + * @param \DOMDocument $doc XML document + * @param string $content Content to wrap in CDATA + * @return \DOMDocumentFragment + */ + private function createSafeCDATA(\DOMDocument $doc, $content) + { + $parts = explode(']]>', (string) $content); + $fragment = $doc->createDocumentFragment(); + $lastPart = count($parts) - 1; + + foreach ($parts as $index => $part) { + if ($index === 0) { + $part .= $index < $lastPart ? ']]' : ''; + } else { + $part = '>' . $part . ($index < $lastPart ? ']]' : ''); + } + + $fragment->appendChild($doc->createCDATASection($part)); } - return $element; + return $fragment; + } + + /** + * Convert assertion details or stack frames to readable text. + * + * @param mixed $details Assertion details + * @return string + */ + private function formatDetails($details) + { + if (is_array($details) && (isset($details['trace']) || isset($details['params']) || isset($details['msg']))) { + $formatted = isset($details['msg']) ? 'Assertion: ' . $details['msg'] : ''; + if (!empty($details['params'])) { + $formatted .= "\n\nParameters:\n" . $details['params']; + } + if (!empty($details['trace'])) { + $formatted .= "\n\nStack Trace:\n" . $this->formatDetails($details['trace']); + } + return $formatted; + } + + if (is_array($details)) { + $lines = []; + foreach ($details as $frame) { + if (is_array($frame)) { + $file = isset($frame['file']) ? $frame['file'] : 'unknown'; + $line = isset($frame['line']) ? $frame['line'] : '?'; + $class = isset($frame['class']) ? $frame['class'] : ''; + $function = isset($frame['function']) ? $frame['function'] : 'unknown'; + $call = $class ? $class . '::' . $function : $function; + $lines[] = 'at ' . $call . '() in ' . $file . ':' . $line; + } else { + $lines[] = $frame; + } + } + return implode("\n", $lines); + } + + return $details; } } diff --git a/test-results.xml b/test-results.xml index c5630f9..7e11c14 100644 --- a/test-results.xml +++ b/test-results.xml @@ -1,143 +1,303 @@ - - - - + + + + + + + + - - - + + + + + + + - - - + + + + + + + - - - + + + + + + + - - + + + + + + + + + - - + + + + + + + + + + + - - + + - - + + + + + + + + + + + + + + + + + - - - + + + + + + - - + + - - + + + + + + - - + + - + + + - - - - + + + + + + + - - + + - - - + + - - - - + + - - - - + + + - - + + + + + + + - - - - - + + + + + + - - - + + + + + + - - + + + - - - - - + + + + + + - - + + + - - - - - - - - - - - + + + + + + + - - + + + + + - - - - - - + + + + + + - + - - + + + + + + - + From 9e9007247a107727a8fcebd8809c8d7dd368b393 Mon Sep 17 00:00:00 2001 From: ruthbri Date: Fri, 4 Sep 2026 13:02:41 -0300 Subject: [PATCH 3/4] testsuite name #60 --- src/JunitXmlBuilder.php | 28 +++++++++-- test-results.xml | 106 ++++++++++------------------------------ 2 files changed, 50 insertions(+), 84 deletions(-) diff --git a/src/JunitXmlBuilder.php b/src/JunitXmlBuilder.php index 1592aff..c0db414 100644 --- a/src/JunitXmlBuilder.php +++ b/src/JunitXmlBuilder.php @@ -49,12 +49,19 @@ public function addTestSuite($name) */ public function addReports(array $reports) { + $suitesByNamespace = []; + foreach ($reports as $suiteReports) { foreach ($suiteReports as $className => $tests) { - $suite = $this->addTestSuite($className); + $namespace = $this->extractNamespace($className); + + if (!isset($suitesByNamespace[$namespace])) { + $suitesByNamespace[$namespace] = $this->addTestSuite($namespace); + } + $suite = $suitesByNamespace[$namespace]; foreach ($tests as $testName => $report) { - $testCase = $suite->addTestCase($testName); + $testCase = $suite->addTestCase($testName, $className); foreach (isset($report['asserts']) ? $report['asserts'] : [] as $assert) { $type = isset($assert['type']) ? $assert['type'] : ''; @@ -99,6 +106,18 @@ private function buildAssertionMessage($testName, $message) return '[' . $testName . '] ' . $message; } + /** + * Get the namespace. + * + * @param string $className Fully qualified class name + * @return string + */ + private function extractNamespace($className) + { + $pos = strrpos($className, '\\'); + return $pos !== false ? substr($className, 0, $pos + 1) : $className; + } + /** * Clear suites previously added to the builder. * @@ -224,11 +243,12 @@ public function __construct($name) * Add a test case to this suite * * @param string $name The test case name (typically the test method name) + * @param string|null $classname The fully qualified test class name (defaults to the suite name) * @return TestCase */ - public function addTestCase($name) + public function addTestCase($name, $classname = null) { - $testCase = new TestCase($name, $this->name); + $testCase = new TestCase($name, $classname !== null ? $classname : $this->name); $this->testCases[] = $testCase; return $testCase; } diff --git a/test-results.xml b/test-results.xml index 7e11c14..b142c08 100644 --- a/test-results.xml +++ b/test-results.xml @@ -1,6 +1,6 @@ - - + + @@ -8,52 +8,34 @@ - - - - - - - + - - - - - - - + - + - + - - - - - - - + - - + + - + - - - - - - - - - - - - - + - + @@ -113,16 +83,10 @@ at call_user_func() in /var/www/html/Debbie/src/DebbieSuite.php:81 at CaboLabs\Debbie\DebbieSuite::run() in /var/www/html/Debbie/src/DebbieRun.php:113]]> - - - - - - - + - + - + - + - + @@ -165,7 +129,7 @@ at trigger_error() in /var/www/html/Debbie/tests/fatal_error_with_other_test/Tes - + @@ -179,7 +143,7 @@ at call_user_func() in /var/www/html/Debbie/src/DebbieSuite.php:81 at CaboLabs\Debbie\DebbieSuite::run() in /var/www/html/Debbie/src/DebbieRun.php:113]]> - + - + - - - - - - @@ -215,7 +173,7 @@ at CaboLabs\Debbie\DebbieSuite::run() in /var/www/html/Debbie/src/DebbieRun.php: - + @@ -236,14 +194,8 @@ at call_user_func() in /var/www/html/Debbie/src/DebbieSuite.php:81 at CaboLabs\Debbie\DebbieSuite::run() in /var/www/html/Debbie/src/DebbieRun.php:113]]> - - - - - - - - + + @@ -280,7 +232,7 @@ at CaboLabs\Debbie\DebbieSuite::run() in /var/www/html/Debbie/src/DebbieRun.php: - + @@ -289,15 +241,9 @@ at CaboLabs\Debbie\DebbieSuite::run() in /var/www/html/Debbie/src/DebbieRun.php: - - - - - - - + - + From e7a4faf95fa1bb0cd7847733e7a64b059fbac9aa Mon Sep 17 00:00:00 2001 From: ruthbri Date: Wed, 16 Sep 2026 14:28:03 -0300 Subject: [PATCH 4/4] barra #60 --- src/JunitXmlBuilder.php | 2 +- test-results.xml | 50 ++++++++++++++++++++--------------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/JunitXmlBuilder.php b/src/JunitXmlBuilder.php index c0db414..905dbb1 100644 --- a/src/JunitXmlBuilder.php +++ b/src/JunitXmlBuilder.php @@ -115,7 +115,7 @@ private function buildAssertionMessage($testName, $message) private function extractNamespace($className) { $pos = strrpos($className, '\\'); - return $pos !== false ? substr($className, 0, $pos + 1) : $className; + return $pos !== false ? substr($className, 0, $pos) : $className; } /** diff --git a/test-results.xml b/test-results.xml index b142c08..1307d5a 100644 --- a/test-results.xml +++ b/test-results.xml @@ -1,6 +1,6 @@ - - + + @@ -8,33 +8,33 @@ - + - + - + - + - + - + - + - + - + - + @@ -103,12 +103,12 @@ at CaboLabs\Debbie\DebbieSuite::run() in /var/www/html/Debbie/src/DebbieRun.php: - + - + @@ -120,16 +120,16 @@ at CaboLabs\Debbie\DebbieSuite::run() in /var/www/html/Debbie/src/DebbieRun.php: - + - + - + @@ -143,7 +143,7 @@ at call_user_func() in /var/www/html/Debbie/src/DebbieSuite.php:81 at CaboLabs\Debbie\DebbieSuite::run() in /var/www/html/Debbie/src/DebbieRun.php:113]]> - + - + @@ -173,7 +173,7 @@ at CaboLabs\Debbie\DebbieSuite::run() in /var/www/html/Debbie/src/DebbieRun.php: - + @@ -194,8 +194,8 @@ at call_user_func() in /var/www/html/Debbie/src/DebbieSuite.php:81 at CaboLabs\Debbie\DebbieSuite::run() in /var/www/html/Debbie/src/DebbieRun.php:113]]> - - + + @@ -232,15 +232,15 @@ at CaboLabs\Debbie\DebbieSuite::run() in /var/www/html/Debbie/src/DebbieRun.php: - + - + - +