|
| 1 | +"""Unit tests for builder sidecar API call logging. |
| 2 | +
|
| 3 | +NOTE: The _make_log_entry function is duplicated here because the template |
| 4 | +file (oss_crs_builder_server.py) imports FastAPI which is not available in |
| 5 | +the test environment. If the implementation changes, these tests must be |
| 6 | +updated to match. The canonical source is: |
| 7 | + oss_crs/src/templates/oss_crs_builder_server.py |
| 8 | +""" |
| 9 | + |
| 10 | +import json |
| 11 | +from pathlib import Path |
| 12 | + |
| 13 | + |
| 14 | +# --------------------------------------------------------------------------- |
| 15 | +# Duplicated from oss_crs_builder_server.py (see NOTE above) |
| 16 | +# --------------------------------------------------------------------------- |
| 17 | + |
| 18 | +_EXIT_CODE_KEYS = { |
| 19 | + "build": "build_exit_code", |
| 20 | + "run-pov": "pov_exit_code", |
| 21 | + "run-test": "test_exit_code", |
| 22 | +} |
| 23 | + |
| 24 | + |
| 25 | +def _make_log_entry( |
| 26 | + action: str, |
| 27 | + job_id: str, |
| 28 | + result: dict, |
| 29 | + duration_ms: int, |
| 30 | + ts_start: float, |
| 31 | +) -> dict: |
| 32 | + """Create a structured log entry for an API call.""" |
| 33 | + exit_code = result.get(_EXIT_CODE_KEYS.get(action, ""), -1) |
| 34 | + entry: dict = { |
| 35 | + "ts": ts_start, |
| 36 | + "api": action, |
| 37 | + "job_id": job_id, |
| 38 | + "duration_ms": duration_ms, |
| 39 | + "exit_code": exit_code, |
| 40 | + } |
| 41 | + for key in ("harness", "build_id"): |
| 42 | + if key in result: |
| 43 | + entry[key] = result[key] |
| 44 | + if "error" in result: |
| 45 | + entry["error"] = result["error"] |
| 46 | + if action == "build": |
| 47 | + entry["build_success"] = exit_code == 0 |
| 48 | + elif action == "run-pov": |
| 49 | + entry["crash"] = exit_code > 0 and exit_code != 124 |
| 50 | + entry["timeout"] = exit_code == 124 |
| 51 | + elif action == "run-test": |
| 52 | + entry["test_passed"] = exit_code == 0 |
| 53 | + entry["skipped"] = bool(result.get("test_skipped")) |
| 54 | + return entry |
| 55 | + |
| 56 | + |
| 57 | +# --------------------------------------------------------------------------- |
| 58 | +# Tests: _make_log_entry |
| 59 | +# --------------------------------------------------------------------------- |
| 60 | + |
| 61 | + |
| 62 | +class TestMakeLogEntryBuild: |
| 63 | + """Tests for build API log entries.""" |
| 64 | + |
| 65 | + def test_success(self): |
| 66 | + e = _make_log_entry("build", "abc123", {"build_exit_code": 0}, 8920, 1000.0) |
| 67 | + assert e["api"] == "build" |
| 68 | + assert e["exit_code"] == 0 |
| 69 | + assert e["build_success"] is True |
| 70 | + assert e["ts"] == 1000.0 |
| 71 | + assert e["duration_ms"] == 8920 |
| 72 | + |
| 73 | + def test_failure(self): |
| 74 | + e = _make_log_entry("build", "def456", {"build_exit_code": 1}, 5000, 1000.0) |
| 75 | + assert e["build_success"] is False |
| 76 | + |
| 77 | + def test_no_build_id_in_entry(self): |
| 78 | + """Build entries use job_id as build_id; no separate build_id field.""" |
| 79 | + e = _make_log_entry("build", "abc123", {"build_exit_code": 0}, 100, 1000.0) |
| 80 | + assert "build_id" not in e |
| 81 | + |
| 82 | + def test_handler_exception(self): |
| 83 | + e = _make_log_entry("build", "j1", {"error": "compile crashed"}, 100, 1000.0) |
| 84 | + assert e["exit_code"] == -1 |
| 85 | + assert e["build_success"] is False |
| 86 | + assert e["error"] == "compile crashed" |
| 87 | + |
| 88 | + |
| 89 | +class TestMakeLogEntryRunPov: |
| 90 | + """Tests for run-pov API log entries.""" |
| 91 | + |
| 92 | + def test_crash(self): |
| 93 | + result = {"pov_exit_code": 1, "harness": "html", "build_id": "base"} |
| 94 | + e = _make_log_entry("run-pov", "p1", result, 2340, 1000.0) |
| 95 | + assert e["crash"] is True |
| 96 | + assert e["timeout"] is False |
| 97 | + assert e["harness"] == "html" |
| 98 | + assert e["build_id"] == "base" |
| 99 | + |
| 100 | + def test_no_crash(self): |
| 101 | + result = {"pov_exit_code": 0, "harness": "fuzz", "build_id": "abc"} |
| 102 | + e = _make_log_entry("run-pov", "p2", result, 1500, 1000.0) |
| 103 | + assert e["crash"] is False |
| 104 | + assert e["timeout"] is False |
| 105 | + |
| 106 | + def test_timeout(self): |
| 107 | + result = {"pov_exit_code": 124, "harness": "fuzz", "build_id": "base"} |
| 108 | + e = _make_log_entry("run-pov", "p3", result, 30000, 1000.0) |
| 109 | + assert e["crash"] is False |
| 110 | + assert e["timeout"] is True |
| 111 | + assert e["exit_code"] == 124 |
| 112 | + |
| 113 | + def test_exit_77_is_crash(self): |
| 114 | + """Exit 77 = libFuzzer security finding, should be classified as crash.""" |
| 115 | + result = {"pov_exit_code": 77, "harness": "fuzz", "build_id": "base"} |
| 116 | + e = _make_log_entry("run-pov", "p4", result, 100, 1000.0) |
| 117 | + assert e["crash"] is True |
| 118 | + assert e["exit_code"] == 77 |
| 119 | + |
| 120 | + def test_handler_exception_not_crash(self): |
| 121 | + """Handler exception (exit_code=-1) must not be classified as crash.""" |
| 122 | + e = _make_log_entry( |
| 123 | + "run-pov", "p5", {"error": "connection refused"}, 100, 1000.0 |
| 124 | + ) |
| 125 | + assert e["crash"] is False |
| 126 | + assert e["exit_code"] == -1 |
| 127 | + assert e["error"] == "connection refused" |
| 128 | + |
| 129 | + def test_harness_not_found(self): |
| 130 | + """Exit 127 = harness binary missing, still classified as crash.""" |
| 131 | + result = {"pov_exit_code": 127, "harness": "missing", "build_id": "base"} |
| 132 | + e = _make_log_entry("run-pov", "p6", result, 50, 1000.0) |
| 133 | + assert e["crash"] is True |
| 134 | + assert e["harness"] == "missing" |
| 135 | + |
| 136 | + |
| 137 | +class TestMakeLogEntryRunTest: |
| 138 | + """Tests for run-test API log entries.""" |
| 139 | + |
| 140 | + def test_pass(self): |
| 141 | + result = {"test_exit_code": 0, "test_skipped": False, "build_id": "abc"} |
| 142 | + e = _make_log_entry("run-test", "t1", result, 45000, 1000.0) |
| 143 | + assert e["test_passed"] is True |
| 144 | + assert e["skipped"] is False |
| 145 | + assert e["build_id"] == "abc" |
| 146 | + |
| 147 | + def test_fail(self): |
| 148 | + result = {"test_exit_code": 1, "test_skipped": False, "build_id": "abc"} |
| 149 | + e = _make_log_entry("run-test", "t2", result, 30000, 1000.0) |
| 150 | + assert e["test_passed"] is False |
| 151 | + assert e["skipped"] is False |
| 152 | + |
| 153 | + def test_skipped(self): |
| 154 | + result = {"test_exit_code": 0, "test_skipped": True, "build_id": "abc"} |
| 155 | + e = _make_log_entry("run-test", "t3", result, 50, 1000.0) |
| 156 | + assert e["test_passed"] is True |
| 157 | + assert e["skipped"] is True |
| 158 | + |
| 159 | + def test_handler_exception(self): |
| 160 | + e = _make_log_entry("run-test", "t4", {"error": "timeout"}, 100, 1000.0) |
| 161 | + assert e["exit_code"] == -1 |
| 162 | + assert e["test_passed"] is False |
| 163 | + assert e["error"] == "timeout" |
| 164 | + |
| 165 | + |
| 166 | +class TestMakeLogEntryCommon: |
| 167 | + """Tests for common log entry fields.""" |
| 168 | + |
| 169 | + def test_timestamp_is_start_time(self): |
| 170 | + ts = 1774296986.79 |
| 171 | + e = _make_log_entry("build", "j1", {"build_exit_code": 0}, 1000, ts) |
| 172 | + assert e["ts"] == ts |
| 173 | + |
| 174 | + def test_required_fields_present(self): |
| 175 | + for action, result in [ |
| 176 | + ("build", {"build_exit_code": 0}), |
| 177 | + ("run-pov", {"pov_exit_code": 1, "harness": "h", "build_id": "b"}), |
| 178 | + ("run-test", {"test_exit_code": 0, "test_skipped": False, "build_id": "b"}), |
| 179 | + ]: |
| 180 | + e = _make_log_entry(action, "j1", result, 100, 1000.0) |
| 181 | + assert all( |
| 182 | + k in e for k in ("ts", "api", "job_id", "duration_ms", "exit_code") |
| 183 | + ) |
| 184 | + |
| 185 | + def test_all_entries_json_serializable(self): |
| 186 | + entries = [ |
| 187 | + _make_log_entry("build", "j1", {"build_exit_code": 0}, 100, 1000.0), |
| 188 | + _make_log_entry( |
| 189 | + "run-pov", |
| 190 | + "j2", |
| 191 | + {"pov_exit_code": 1, "harness": "h", "build_id": "b"}, |
| 192 | + 100, |
| 193 | + 1000.0, |
| 194 | + ), |
| 195 | + _make_log_entry( |
| 196 | + "run-test", |
| 197 | + "j3", |
| 198 | + {"test_exit_code": 0, "test_skipped": False, "build_id": "b"}, |
| 199 | + 100, |
| 200 | + 1000.0, |
| 201 | + ), |
| 202 | + _make_log_entry("run-pov", "j4", {"error": "fail"}, 100, 1000.0), |
| 203 | + ] |
| 204 | + for entry in entries: |
| 205 | + line = json.dumps(entry) |
| 206 | + assert json.loads(line) == entry |
| 207 | + |
| 208 | + def test_unknown_action(self): |
| 209 | + e = _make_log_entry("unknown", "j1", {}, 100, 1000.0) |
| 210 | + assert e["exit_code"] == -1 |
| 211 | + assert "crash" not in e |
| 212 | + assert "build_success" not in e |
| 213 | + assert "test_passed" not in e |
| 214 | + |
| 215 | + |
| 216 | +# --------------------------------------------------------------------------- |
| 217 | +# Tests: JSONL file writing |
| 218 | +# --------------------------------------------------------------------------- |
| 219 | + |
| 220 | + |
| 221 | +class TestLogApiCallFile: |
| 222 | + """Tests for JSONL file writing mechanics.""" |
| 223 | + |
| 224 | + def _write_entry(self, log_file: Path, entry: dict) -> None: |
| 225 | + with log_file.open("a") as f: |
| 226 | + f.write(json.dumps(entry, separators=(",", ":")) + "\n") |
| 227 | + |
| 228 | + def test_writes_jsonl_lines(self, tmp_path): |
| 229 | + log_file = tmp_path / "api-calls.jsonl" |
| 230 | + log_file.touch() |
| 231 | + self._write_entry(log_file, {"api": "build", "exit_code": 0}) |
| 232 | + self._write_entry(log_file, {"api": "run-pov", "exit_code": 1}) |
| 233 | + |
| 234 | + lines = log_file.read_text().strip().split("\n") |
| 235 | + assert len(lines) == 2 |
| 236 | + assert json.loads(lines[0])["api"] == "build" |
| 237 | + assert json.loads(lines[1])["api"] == "run-pov" |
| 238 | + |
| 239 | + def test_empty_file_means_zero_calls(self, tmp_path): |
| 240 | + log_file = tmp_path / "api-calls.jsonl" |
| 241 | + log_file.touch() |
| 242 | + assert log_file.exists() |
| 243 | + assert log_file.read_text() == "" |
| 244 | + |
| 245 | + def test_compact_json_no_spaces(self, tmp_path): |
| 246 | + log_file = tmp_path / "api-calls.jsonl" |
| 247 | + log_file.touch() |
| 248 | + self._write_entry(log_file, {"api": "build", "exit_code": 0}) |
| 249 | + line = log_file.read_text().strip() |
| 250 | + assert " " not in line # compact separators |
| 251 | + |
| 252 | + def test_each_line_is_valid_json(self, tmp_path): |
| 253 | + log_file = tmp_path / "api-calls.jsonl" |
| 254 | + log_file.touch() |
| 255 | + for i in range(5): |
| 256 | + self._write_entry(log_file, {"api": "run-pov", "n": i}) |
| 257 | + lines = log_file.read_text().strip().split("\n") |
| 258 | + assert len(lines) == 5 |
| 259 | + for line in lines: |
| 260 | + json.loads(line) # should not raise |
0 commit comments