| 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 | TTS_DISABLE_WARNING_PUSH | ||
| 14 | TTS_DISABLE_WARNING_CRT_SECURE | ||
| 15 | |||
| 16 | namespace tts::_ | ||
| 17 | { | ||
| 18 | // Test names and failure messages are arbitrary user text - this is what keeps them valid JSON. | ||
| 19 | 4 | inline ::tts::text json_escape(::tts::text const& t) | |
| 20 | { | ||
| 21 | 4 | ::tts::text out; | |
| 22 |
2/2✓ Branch 2 taken 101 times.
✓ Branch 3 taken 4 times.
|
105 | for(char c: t) |
| 23 | { | ||
| 24 |
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 101 times.
|
101 | switch(c) |
| 25 | { | ||
| 26 | ✗ | case '"': out += R"(\")"; break; | |
| 27 | ✗ | case '\\': out += R"(\\)"; break; | |
| 28 | ✗ | case '\n': out += "\\n"; break; | |
| 29 | ✗ | case '\r': out += "\\r"; break; | |
| 30 | ✗ | case '\t': out += "\\t"; break; | |
| 31 | 101 | default: | |
| 32 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 101 times.
|
101 | if(static_cast<unsigned char>(c) < 0x20) |
| 33 | ✗ | out += ::tts::text {"\\u%04x", static_cast<unsigned>(static_cast<unsigned char>(c))}; | |
| 34 |
1/1✓ Branch 2 taken 101 times.
|
101 | else out += ::tts::text {"%c", c}; |
| 35 | 101 | break; | |
| 36 | } | ||
| 37 | } | ||
| 38 | 4 | return out; | |
| 39 | ✗ | } | |
| 40 | } | ||
| 41 | |||
| 42 | namespace tts | ||
| 43 | { | ||
| 44 | //==================================================================================================================== | ||
| 45 | /** | ||
| 46 | @ingroup tools-sinks | ||
| 47 | @public | ||
| 48 | @brief output_sink rendering the run as a single structured JSON document. | ||
| 49 | |||
| 50 | Built from @ref output_sink's structured hooks rather than by parsing the human-readable text | ||
| 51 | @ref tts::stdout_sink prints, so it stays correct regardless of `-v`/`-q`. Every @ref TTS_CASE | ||
| 52 | becomes one entry in a `"tests"` array (name, status, duration in nanoseconds, and any | ||
| 53 | failing/fatal assertions gathered while it ran), summarized by a `"summary"` object counting | ||
| 54 | passed/failed/invalid cases - counted from `test_finished()` directly, not from the suite's | ||
| 55 | raw assertion totals, so `tests.length` always matches `summary.total`. Rendered by `dump()`, | ||
| 56 | or automatically via `finish()`, to the target given at construction | ||
| 57 | (`tts::output_handler::default_sink()` by default). It does not (currently) reflect | ||
| 58 | @ref TTS_CASE_TPL's per-type breakdown or @ref TTS_WHEN / @ref TTS_AND_THEN sub-scenarios, | ||
| 59 | since those don't have their own hook - only the enclosing @ref TTS_CASE does. | ||
| 60 | |||
| 61 | @groupheader{Example} | ||
| 62 | @code | ||
| 63 | tts::json_sink json; | ||
| 64 | tts::output().sink(json); | ||
| 65 | |||
| 66 | // ... run the test suite ... | ||
| 67 | |||
| 68 | json.dump(); // stream the JSON report to stdout | ||
| 69 | @endcode | ||
| 70 | **/ | ||
| 71 | //==================================================================================================================== | ||
| 72 | struct json_sink : output_sink | ||
| 73 | { | ||
| 74 | 100 | explicit json_sink(output_sink& target = output_handler::default_sink()) | |
| 75 | 100 | : target_(&target) | |
| 76 | { | ||
| 77 | 100 | } | |
| 78 | |||
| 79 | 10 | void write(text const&) override | |
| 80 | { | ||
| 81 | // Intentionally empty: the JSON report is built entirely from the structured hooks below. | ||
| 82 | 10 | } | |
| 83 | |||
| 84 | 1 | void assertion_failed(text const& location, text const& message, bool fatal) override | |
| 85 | { | ||
| 86 | // location is "[file:line]" - strip the brackets, split so line is a real integer. | ||
| 87 | 1 | char const* loc = location.data(); | |
| 88 | 1 | std::size_t len = strlen(loc); // NOSONAR - loc always comes from a well-formed tts::text | |
| 89 | 1 | auto stripped = text {"%.*s", static_cast<int>(len - 2), loc + 1}; | |
| 90 | |||
| 91 | 1 | char const* colon = strrchr(stripped.data(), ':'); // NOSONAR - stripped always has one | |
| 92 | text file = | ||
| 93 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | colon ? text {"%.*s", static_cast<int>(colon - stripped.data()), stripped.data()} : stripped; |
| 94 | 1 | int line = 0; | |
| 95 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | if(colon) sscanf(colon + 1, "%d", &line); // NOSONAR - colon+1 is always digits here |
| 96 | |||
| 97 |
1/3✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 4 not taken.
|
1 | if(!current_failures_.is_empty()) current_failures_ += ","; |
| 98 | 1 | current_failures_ += text {R"({"location":{"file":"%s","line":%d},"message":"%s",)" | |
| 99 | R"("fatal":%s})", | ||
| 100 |
1/1✓ Branch 1 taken 1 times.
|
1 | _::json_escape(file).data(), |
| 101 | line, | ||
| 102 | 1 | _::json_escape(message).data(), | |
| 103 |
3/4✓ Branch 2 taken 1 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✓ Branch 9 taken 1 times.
|
1 | fatal ? "true" : "false"}; |
| 104 | 1 | } | |
| 105 | |||
| 106 | void | ||
| 107 | 2 | test_finished(text const& name, bool passed, bool invalid, nanoseconds duration_ns) override | |
| 108 | { | ||
| 109 | 2 | char const* status = "failed"; | |
| 110 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
|
2 | if(invalid) |
| 111 | { | ||
| 112 | ✗ | status = "invalid"; | |
| 113 | ✗ | ++invalid_count_; | |
| 114 | } | ||
| 115 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
|
2 | else if(passed) |
| 116 | { | ||
| 117 | 1 | status = "passed"; | |
| 118 | 1 | ++passed_count_; | |
| 119 | } | ||
| 120 | 1 | else ++failed_count_; | |
| 121 | 2 | total_duration_ns_ += duration_ns; | |
| 122 | |||
| 123 |
2/2✓ Branch 1 taken 1 times.
✓ Branch 2 taken 1 times.
|
2 | if(!body_.is_empty()) body_ += ","; |
| 124 | 2 | body_ += text {R"({"name":"%s","status":"%s","duration_ns":%llu,"failures":[%s]})", | |
| 125 |
1/1✓ Branch 1 taken 2 times.
|
4 | _::json_escape(name).data(), |
| 126 | status, | ||
| 127 | duration_ns, | ||
| 128 |
1/1✓ Branch 4 taken 2 times.
|
2 | current_failures_.data()}; |
| 129 | |||
| 130 | 2 | current_failures_ = text {}; | |
| 131 | 2 | } | |
| 132 | |||
| 133 | /// Renders everything gathered so far as a single JSON document. | ||
| 134 | 1 | text render() const | |
| 135 | { | ||
| 136 | 1 | counter total = passed_count_ + failed_count_ + invalid_count_; | |
| 137 | return text {R"({"tests":[%s],"summary":{"total":%llu,"passed":%llu,"failed":%llu,)" | ||
| 138 | R"("invalid":%llu,"duration_ns":%llu}})", | ||
| 139 | body_.data(), | ||
| 140 | total, | ||
| 141 | 1 | passed_count_, | |
| 142 | 1 | failed_count_, | |
| 143 | 1 | invalid_count_, | |
| 144 | 1 | total_duration_ns_}; | |
| 145 | } | ||
| 146 | |||
| 147 | /// Forwards the JSON-rendered report to target, then clears the gathered results. | ||
| 148 | 1 | void dump(output_sink& target) | |
| 149 | { | ||
| 150 |
2/2✓ Branch 1 taken 1 times.
✓ Branch 4 taken 1 times.
|
1 | target.write(render()); |
| 151 | 1 | clear(); | |
| 152 | 1 | } | |
| 153 | |||
| 154 | /// Streams the JSON-rendered report to `stdout`, then clears the gathered results. | ||
| 155 | void dump() | ||
| 156 | { | ||
| 157 | stdout_sink target; | ||
| 158 | dump(target); | ||
| 159 | } | ||
| 160 | |||
| 161 | /// Discards everything gathered so far. | ||
| 162 | 1 | void clear() | |
| 163 | { | ||
| 164 | 1 | body_ = text {}; | |
| 165 | 1 | current_failures_ = text {}; | |
| 166 | 1 | passed_count_ = 0; | |
| 167 | 1 | failed_count_ = 0; | |
| 168 | 1 | invalid_count_ = 0; | |
| 169 | 1 | total_duration_ns_ = 0; | |
| 170 | 1 | } | |
| 171 | |||
| 172 | /// Dumps the JSON-rendered report to the target given at construction. | ||
| 173 | ✗ | void finish() override | |
| 174 | { | ||
| 175 | ✗ | dump(*target_); | |
| 176 | ✗ | } | |
| 177 | |||
| 178 | private: | ||
| 179 | output_sink* target_; | ||
| 180 | text body_; | ||
| 181 | text current_failures_; | ||
| 182 | counter passed_count_ = 0; | ||
| 183 | counter failed_count_ = 0; | ||
| 184 | counter invalid_count_ = 0; | ||
| 185 | nanoseconds total_duration_ns_ = 0; | ||
| 186 | }; | ||
| 187 | } | ||
| 188 | |||
| 189 | TTS_DISABLE_WARNING_POP | ||
| 190 |