|
1 | 1 | import dataclasses |
| 2 | +import html |
2 | 3 | import json |
3 | | -import os |
4 | | -from _colorize import get_colors # type: ignore[import-not-found] |
| 4 | +import pathlib |
| 5 | +from _colorize import decolor, get_colors # type: ignore[import-not-found] |
5 | 6 | from typing import Any |
6 | 7 |
|
7 | 8 | from .findtests import findtestdir |
@@ -182,25 +183,49 @@ def __str__(self) -> str: |
182 | 183 | def has_meaningful_duration(self): |
183 | 184 | return State.has_meaningful_duration(self.state) |
184 | 185 |
|
| 186 | + @property |
| 187 | + def test_cases(self) -> list[tuple[str, str]]: |
| 188 | + """Failed test cases: list of (name, traceback).""" |
| 189 | + return (self.errors or []) + (self.failures or []) |
| 190 | + |
| 191 | + def format_github_summary(self, expand: bool) -> str: |
| 192 | + """Format the Markdown section of the GitHub Actions job summary.""" |
| 193 | + blocks = [f"### {decolor(str(self))}"] |
| 194 | + if self.env_changed_reasons: |
| 195 | + blocks.append("\n".join(f"- {reason}" |
| 196 | + for reason in self.env_changed_reasons)) |
| 197 | + for name, traceback in self.test_cases: |
| 198 | + # Expand the tracebacks if they fit on a screen: a traceback of |
| 199 | + # less than 30 lines |
| 200 | + is_open = expand and traceback.count("\n") < 30 |
| 201 | + blocks += [f"<details{' open' if is_open else ''}>" |
| 202 | + f"<summary>{html.escape(name)}</summary>", |
| 203 | + f"```pytb\n{decolor(traceback).rstrip()}\n```", |
| 204 | + "</details>"] |
| 205 | + # Separate Markdown blocks with an empty line |
| 206 | + return "".join(f"{block}\n\n" for block in blocks) |
| 207 | + |
185 | 208 | def print_github_annotation(self, runtests: RunTests) -> None: |
186 | | - """Annotate a failed test without test case failures. |
| 209 | + """Annotate each failed test case, or else the failed test file. |
187 | 210 |
|
188 | | - For example: crash, timeout, env changed. Locate the annotation in the |
189 | | - test file. Test case failures are annotated by the test runner, where |
190 | | - they are reported (see RegressionTestResult.printErrorList()). |
| 211 | + For example, the test file for a crash, timeout or env changed. |
191 | 212 | """ |
192 | | - if (runtests.github_annotations |
193 | | - and self.is_failed(runtests.fail_env_changed) |
194 | | - and not self.errors and not self.failures): |
| 213 | + if not (runtests.github_annotations |
| 214 | + and self.is_failed(runtests.fail_env_changed)): |
| 215 | + return |
| 216 | + # Test file: "test_x" is test_x.py or test_x/__init__.py, |
| 217 | + # "test.test_x.test_y" is test_x/test_y.py |
| 218 | + name = self.test_name.removeprefix("test.") |
| 219 | + path = pathlib.Path(findtestdir(runtests.test_dir), *name.split(".")) |
| 220 | + filename = path.with_suffix(".py") |
| 221 | + if not filename.exists(): |
| 222 | + filename = path / "__init__.py" |
| 223 | + test_cases = self.test_cases |
| 224 | + if not test_cases: |
195 | 225 | message = "\n".join([str(self), *(self.env_changed_reasons or ())]) |
196 | | - # Test file: "test_x" is test_x.py or test_x/__init__.py, |
197 | | - # "test.test_x.test_y" is test_x/test_y.py |
198 | | - name = self.test_name.removeprefix("test.") |
199 | | - path = os.path.join(findtestdir(runtests.test_dir), *name.split(".")) |
200 | | - filename = path + ".py" |
201 | | - if not os.path.exists(filename): |
202 | | - filename = os.path.join(path, "__init__.py") |
203 | | - print_github_annotation(self.test_name, message, filename) |
| 226 | + test_cases = [(self.test_name, message)] |
| 227 | + for title, message in test_cases: |
| 228 | + print_github_annotation(title, message, str(filename)) |
204 | 229 |
|
205 | 230 | def set_env_changed(self, *reasons): |
206 | 231 | if self.state is None or self.state == State.PASSED: |
|
0 commit comments