|
1 | 1 | import dataclasses |
2 | 2 | import json |
3 | | -from _colorize import get_colors # type: ignore[import-not-found] |
| 3 | +import os |
| 4 | +from _colorize import decolor, get_colors # type: ignore[import-not-found] |
4 | 5 | from typing import Any |
5 | 6 |
|
6 | 7 | from .utils import ( |
7 | 8 | StrJSON, TestName, FilterTuple, |
8 | | - format_duration, normalize_test_name, print_warning) |
| 9 | + format_duration, normalize_test_name, print_warning, traceback_location) |
9 | 10 |
|
10 | 11 |
|
11 | 12 | @dataclasses.dataclass(slots=True) |
@@ -178,6 +179,40 @@ def __str__(self) -> str: |
178 | 179 | def has_meaningful_duration(self): |
179 | 180 | return State.has_meaningful_duration(self.state) |
180 | 181 |
|
| 182 | + def print_github_annotations(self) -> None: |
| 183 | + """Print a GitHub Actions error annotation per failed test case.""" |
| 184 | + if not os.environ.get("GITHUB_STEP_SUMMARY"): |
| 185 | + return |
| 186 | + |
| 187 | + def escape(text: str) -> str: |
| 188 | + return (text.replace("%", "%25").replace("\r", "%0D") |
| 189 | + .replace("\n", "%0A")) |
| 190 | + |
| 191 | + def escape_property(text: str) -> str: |
| 192 | + return escape(text).replace(":", "%3A").replace(",", "%2C") |
| 193 | + |
| 194 | + annotations = [(f"{self.test_name}: {name}", traceback) |
| 195 | + for name, traceback in (self.errors or []) |
| 196 | + + (self.failures or [])] |
| 197 | + if not annotations: |
| 198 | + # No test case details: crash, timeout, env changed, etc. |
| 199 | + message = "\n".join([str(self), *(self.env_changed_reasons or ())]) |
| 200 | + annotations = [(self.test_name, message)] |
| 201 | + for title, message in annotations: |
| 202 | + message = decolor(message) |
| 203 | + props: dict[str, str | int] = {} |
| 204 | + if location := traceback_location(message): |
| 205 | + props |= location |
| 206 | + # The job log only shows the message: start it with the location |
| 207 | + position = ":".join(str(location[key]) |
| 208 | + for key in ("file", "line", "col") |
| 209 | + if key in location) |
| 210 | + message = f"{position}\n{message}" |
| 211 | + props["title"] = title |
| 212 | + props_text = ",".join(f"{key}={escape_property(str(value))}" |
| 213 | + for key, value in props.items()) |
| 214 | + print(f"::error {props_text}::{escape(message)}", flush=True) |
| 215 | + |
181 | 216 | def set_env_changed(self, *reasons): |
182 | 217 | if self.state is None or self.state == State.PASSED: |
183 | 218 | self.state = State.ENV_CHANGED |
|
0 commit comments