GCC Code Coverage Report


Directory: include/tts/
File: include/tts/sinks/junit.hpp
Date: 2026-10-14 16:28:14
Exec Total Coverage
Lines: 56 67 83.6%
Functions: 8 9 88.9%
Branches: 21 43 48.8%

Line Branch Exec Source
1 //======================================================================================================================
2 //! @file
3 /*
4 TTS - Tiny Test System
5 Copyright : TTS Contributors & Maintainers
6 SPDX-License-Identifier: BSL-1.0
7 */
8 //======================================================================================================================
9 #pragma once
10
11 #include <tts/tools/output.hpp>
12
13 namespace tts::_
14 {
15 // Test names and failure messages are arbitrary user text - this is what keeps them valid XML.
16 4 inline ::tts::text xml_escape(::tts::text const& t)
17 {
18 4 ::tts::text out;
19
2/2
✓ Branch 2 taken 130 times.
✓ Branch 3 taken 4 times.
134 for(char c: t)
20 {
21
1/6
✗ Branch 0 not taken.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 130 times.
130 switch(c)
22 {
23 ✗ case '&': out += "&amp;"; break;
24 ✗ case '<': out += "&lt;"; break;
25 ✗ case '>': out += "&gt;"; break;
26 ✗ case '"': out += "&quot;"; break;
27 ✗ case '\'': out += "&apos;"; break;
28 130 default:
29 // XML 1.0 has no valid escape for raw control chars except tab/newline/CR - drop the rest.
30
1/8
✗ Branch 0 not taken.
✓ Branch 1 taken 130 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
130 if(static_cast<unsigned char>(c) >= 0x20 || c == '\t' || c == '\n' || c == '\r')
31
1/1
✓ Branch 2 taken 130 times.
130 out += ::tts::text {"%c", c};
32 130 break;
33 }
34 }
35 4 return out;
36 ✗ }
37 }
38
39 namespace tts
40 {
41 //====================================================================================================================
42 /**
43 @ingroup tools-sinks
44 @public
45 @brief output_sink rendering the run as JUnit XML, the de facto standard consumed by Jenkins,
46 GitLab CI, CircleCI, Azure DevOps, Bitbucket Pipelines and most CI dashboards.
47
48 Built from @ref output_sink's structured hooks rather than by parsing the human-readable text
49 @ref tts::stdout_sink prints, so it stays correct regardless of `-v`/`-q`. Every @ref TTS_CASE
50 becomes one `<testcase>` (name, classname, elapsed time in seconds) inside a single
51 `<testsuite>`, with a `<skipped/>` child for an invalid case or a `<failure>` child gathering
52 every failing assertion's message for a failed one. Rendered by `dump()`, or automatically via
53 `finish()`, to the target given at construction (`tts::output_handler::default_sink()` by
54 default). It does not (currently) reflect @ref TTS_CASE_TPL's per-type breakdown or
55 @ref TTS_WHEN / @ref TTS_AND_THEN sub-scenarios, since those don't have their own hook - only
56 the enclosing @ref TTS_CASE does.
57
58 @groupheader{Example}
59 @code
60 tts::junit_sink junit;
61 tts::output().sink(junit);
62
63 // ... run the test suite ...
64
65 junit.dump(); // stream the JUnit XML report to stdout
66 @endcode
67 **/
68 //====================================================================================================================
69 struct junit_sink : output_sink
70 {
71 100 explicit junit_sink(output_sink& target = output_handler::default_sink())
72 100 : target_(&target)
73 {
74 100 }
75
76 10 void write(text const&) override
77 {
78 // Intentionally empty: the JUnit report is built entirely from the structured hooks below.
79 10 }
80
81 1 void assertion_failed(text const& location,
82 text const& message,
83 [[maybe_unused]] bool fatal) override
84 {
85 // Strip the brackets to match tts::diagnostics_sink's "file:line: message" convention.
86 1 char const* loc = location.data();
87 1 std::size_t len = strlen(loc); // NOSONAR - loc always comes from a well-formed tts::text
88
89
1/2
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
1 if(!current_failures_.is_empty()) current_failures_ += "&#10;"; // XML newline entity
90 current_failures_ +=
91
2/2
✓ Branch 1 taken 1 times.
✓ Branch 6 taken 1 times.
1 text {"%.*s: %s", static_cast<int>(len - 2), loc + 1, _::xml_escape(message).data()};
92
93
2/3
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
✓ Branch 4 taken 1 times.
1 if(first_failure_.is_empty()) first_failure_ = _::xml_escape(message);
94 1 }
95
96 void
97 2 test_finished(text const& name, bool passed, bool invalid, nanoseconds duration_ns) override
98 {
99
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
2 if(invalid) ++invalid_count_;
100
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
2 else if(passed) ++passed_count_;
101 1 else ++failed_count_;
102 2 total_duration_ns_ += duration_ns;
103
104
1/1
✓ Branch 1 taken 2 times.
2 auto escaped_name = _::xml_escape(name);
105 2 auto seconds = text {"%.6f", static_cast<double>(duration_ns) / 1'000'000'000.0};
106
107
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
2 if(invalid)
108 {
109 ✗ body_ += text {R"( <testcase name="%s" classname="%s" time="%s"><skipped/></testcase>)"
110 "\n",
111 escaped_name.data(),
112 escaped_name.data(),
113 ✗ seconds.data()};
114 }
115
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
2 else if(!passed)
116 {
117 1 body_ += text {R"( <testcase name="%s" classname="%s" time="%s"><failure )"
118 R"(message="%s">%s</failure></testcase>)"
119 "\n",
120 escaped_name.data(),
121 escaped_name.data(),
122 seconds.data(),
123 first_failure_.data(),
124
1/1
✓ Branch 7 taken 1 times.
1 current_failures_.data()};
125 }
126 else
127 {
128 1 body_ += text {R"( <testcase name="%s" classname="%s" time="%s"/>)"
129 "\n",
130 escaped_name.data(),
131 escaped_name.data(),
132
1/1
✓ Branch 5 taken 1 times.
1 seconds.data()};
133 }
134
135 2 current_failures_ = text {};
136 2 first_failure_ = text {};
137 2 }
138
139 /// Renders everything gathered so far as a single JUnit XML document.
140 1 text render() const
141 {
142 1 counter total = passed_count_ + failed_count_ + invalid_count_;
143 1 double seconds = static_cast<double>(total_duration_ns_) / 1'000'000'000.0;
144
145 return text {"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
146 R"(<testsuites><testsuite name="TTS" tests="%llu" failures="%llu" errors="0")"
147 R"( skipped="%llu" time="%.6f">)"
148 "\n%s </testsuite></testsuites>\n",
149 total,
150 1 failed_count_,
151 1 invalid_count_,
152 seconds,
153 1 body_.data()};
154 }
155
156 /// Forwards the JUnit-rendered report to target, then clears the gathered results.
157 1 void dump(output_sink& target)
158 {
159
2/2
✓ Branch 1 taken 1 times.
✓ Branch 4 taken 1 times.
1 target.write(render());
160 1 clear();
161 1 }
162
163 /// Streams the JUnit-rendered report to `stdout`, then clears the gathered results.
164 void dump()
165 {
166 stdout_sink target;
167 dump(target);
168 }
169
170 /// Discards everything gathered so far.
171 1 void clear()
172 {
173 1 body_ = text {};
174 1 current_failures_ = text {};
175 1 first_failure_ = text {};
176 1 passed_count_ = 0;
177 1 failed_count_ = 0;
178 1 invalid_count_ = 0;
179 1 total_duration_ns_ = 0;
180 1 }
181
182 /// Dumps the JUnit-rendered report to the target given at construction.
183 ✗ void finish() override
184 {
185 ✗ dump(*target_);
186 ✗ }
187
188 private:
189 output_sink* target_;
190 text body_;
191 text current_failures_;
192 text first_failure_;
193 counter passed_count_ = 0;
194 counter failed_count_ = 0;
195 counter invalid_count_ = 0;
196 nanoseconds total_duration_ns_ = 0;
197 };
198 }
199