|
| 1 | +import dataclasses |
| 2 | +import json |
| 3 | +from typing import Any |
| 4 | + |
| 5 | +from test.support import TestStats |
| 6 | + |
| 7 | +from test.libregrtest.utils import ( |
| 8 | + TestName, FilterTuple, |
| 9 | + format_duration, normalize_test_name, print_warning) |
| 10 | + |
| 11 | + |
| 12 | +# Avoid enum.Enum to reduce the number of imports when tests are run |
| 13 | +class State: |
| 14 | + PASSED = "PASSED" |
| 15 | + FAILED = "FAILED" |
| 16 | + SKIPPED = "SKIPPED" |
| 17 | + UNCAUGHT_EXC = "UNCAUGHT_EXC" |
| 18 | + REFLEAK = "REFLEAK" |
| 19 | + ENV_CHANGED = "ENV_CHANGED" |
| 20 | + RESOURCE_DENIED = "RESOURCE_DENIED" |
| 21 | + INTERRUPTED = "INTERRUPTED" |
| 22 | + MULTIPROCESSING_ERROR = "MULTIPROCESSING_ERROR" |
| 23 | + DID_NOT_RUN = "DID_NOT_RUN" |
| 24 | + TIMEOUT = "TIMEOUT" |
| 25 | + |
| 26 | + @staticmethod |
| 27 | + def is_failed(state): |
| 28 | + return state in { |
| 29 | + State.FAILED, |
| 30 | + State.UNCAUGHT_EXC, |
| 31 | + State.REFLEAK, |
| 32 | + State.MULTIPROCESSING_ERROR, |
| 33 | + State.TIMEOUT} |
| 34 | + |
| 35 | + @staticmethod |
| 36 | + def has_meaningful_duration(state): |
| 37 | + # Consider that the duration is meaningless for these cases. |
| 38 | + # For example, if a whole test file is skipped, its duration |
| 39 | + # is unlikely to be the duration of executing its tests, |
| 40 | + # but just the duration to execute code which skips the test. |
| 41 | + return state not in { |
| 42 | + State.SKIPPED, |
| 43 | + State.RESOURCE_DENIED, |
| 44 | + State.INTERRUPTED, |
| 45 | + State.MULTIPROCESSING_ERROR, |
| 46 | + State.DID_NOT_RUN} |
| 47 | + |
| 48 | + @staticmethod |
| 49 | + def must_stop(state): |
| 50 | + return state in { |
| 51 | + State.INTERRUPTED, |
| 52 | + State.MULTIPROCESSING_ERROR} |
| 53 | + |
| 54 | + |
| 55 | +@dataclasses.dataclass(slots=True) |
| 56 | +class TestResult: |
| 57 | + test_name: TestName |
| 58 | + state: str | None = None |
| 59 | + # Test duration in seconds |
| 60 | + duration: float | None = None |
| 61 | + xml_data: list[str] | None = None |
| 62 | + stats: TestStats | None = None |
| 63 | + |
| 64 | + # errors and failures copied from support.TestFailedWithDetails |
| 65 | + errors: list[tuple[str, str]] | None = None |
| 66 | + failures: list[tuple[str, str]] | None = None |
| 67 | + |
| 68 | + def is_failed(self, fail_env_changed: bool) -> bool: |
| 69 | + if self.state == State.ENV_CHANGED: |
| 70 | + return fail_env_changed |
| 71 | + return State.is_failed(self.state) |
| 72 | + |
| 73 | + def _format_failed(self): |
| 74 | + if self.errors and self.failures: |
| 75 | + le = len(self.errors) |
| 76 | + lf = len(self.failures) |
| 77 | + error_s = "error" + ("s" if le > 1 else "") |
| 78 | + failure_s = "failure" + ("s" if lf > 1 else "") |
| 79 | + return f"{self.test_name} failed ({le} {error_s}, {lf} {failure_s})" |
| 80 | + |
| 81 | + if self.errors: |
| 82 | + le = len(self.errors) |
| 83 | + error_s = "error" + ("s" if le > 1 else "") |
| 84 | + return f"{self.test_name} failed ({le} {error_s})" |
| 85 | + |
| 86 | + if self.failures: |
| 87 | + lf = len(self.failures) |
| 88 | + failure_s = "failure" + ("s" if lf > 1 else "") |
| 89 | + return f"{self.test_name} failed ({lf} {failure_s})" |
| 90 | + |
| 91 | + return f"{self.test_name} failed" |
| 92 | + |
| 93 | + def __str__(self) -> str: |
| 94 | + match self.state: |
| 95 | + case State.PASSED: |
| 96 | + return f"{self.test_name} passed" |
| 97 | + case State.FAILED: |
| 98 | + return self._format_failed() |
| 99 | + case State.SKIPPED: |
| 100 | + return f"{self.test_name} skipped" |
| 101 | + case State.UNCAUGHT_EXC: |
| 102 | + return f"{self.test_name} failed (uncaught exception)" |
| 103 | + case State.REFLEAK: |
| 104 | + return f"{self.test_name} failed (reference leak)" |
| 105 | + case State.ENV_CHANGED: |
| 106 | + return f"{self.test_name} failed (env changed)" |
| 107 | + case State.RESOURCE_DENIED: |
| 108 | + return f"{self.test_name} skipped (resource denied)" |
| 109 | + case State.INTERRUPTED: |
| 110 | + return f"{self.test_name} interrupted" |
| 111 | + case State.MULTIPROCESSING_ERROR: |
| 112 | + return f"{self.test_name} process crashed" |
| 113 | + case State.DID_NOT_RUN: |
| 114 | + return f"{self.test_name} ran no tests" |
| 115 | + case State.TIMEOUT: |
| 116 | + return f"{self.test_name} timed out ({format_duration(self.duration)})" |
| 117 | + case _: |
| 118 | + raise ValueError("unknown result state: {state!r}") |
| 119 | + |
| 120 | + def has_meaningful_duration(self): |
| 121 | + return State.has_meaningful_duration(self.state) |
| 122 | + |
| 123 | + def set_env_changed(self): |
| 124 | + if self.state is None or self.state == State.PASSED: |
| 125 | + self.state = State.ENV_CHANGED |
| 126 | + |
| 127 | + def must_stop(self, fail_fast: bool, fail_env_changed: bool) -> bool: |
| 128 | + if State.must_stop(self.state): |
| 129 | + return True |
| 130 | + if fail_fast and self.is_failed(fail_env_changed): |
| 131 | + return True |
| 132 | + return False |
| 133 | + |
| 134 | + def get_rerun_match_tests(self) -> FilterTuple | None: |
| 135 | + match_tests = [] |
| 136 | + |
| 137 | + errors = self.errors or [] |
| 138 | + failures = self.failures or [] |
| 139 | + for error_list, is_error in ( |
| 140 | + (errors, True), |
| 141 | + (failures, False), |
| 142 | + ): |
| 143 | + for full_name, *_ in error_list: |
| 144 | + match_name = normalize_test_name(full_name, is_error=is_error) |
| 145 | + if match_name is None: |
| 146 | + # 'setUpModule (test.test_sys)': don't filter tests |
| 147 | + return None |
| 148 | + if not match_name: |
| 149 | + error_type = "ERROR" if is_error else "FAIL" |
| 150 | + print_warning(f"rerun failed to parse {error_type} test name: " |
| 151 | + f"{full_name!r}: don't filter tests") |
| 152 | + return None |
| 153 | + match_tests.append(match_name) |
| 154 | + |
| 155 | + if not match_tests: |
| 156 | + return None |
| 157 | + return tuple(match_tests) |
| 158 | + |
| 159 | + def write_json(self, file) -> None: |
| 160 | + json.dump(self, file, cls=_EncodeTestResult) |
| 161 | + |
| 162 | + @staticmethod |
| 163 | + def from_json(worker_json) -> 'TestResult': |
| 164 | + return json.loads(worker_json, object_hook=_decode_test_result) |
| 165 | + |
| 166 | + |
| 167 | +class _EncodeTestResult(json.JSONEncoder): |
| 168 | + def default(self, o: Any) -> dict[str, Any]: |
| 169 | + if isinstance(o, TestResult): |
| 170 | + result = dataclasses.asdict(o) |
| 171 | + result["__test_result__"] = o.__class__.__name__ |
| 172 | + return result |
| 173 | + else: |
| 174 | + return super().default(o) |
| 175 | + |
| 176 | + |
| 177 | +def _decode_test_result(data: dict[str, Any]) -> TestResult | dict[str, Any]: |
| 178 | + if "__test_result__" in data: |
| 179 | + data.pop('__test_result__') |
| 180 | + if data['stats'] is not None: |
| 181 | + data['stats'] = TestStats(**data['stats']) |
| 182 | + return TestResult(**data) |
| 183 | + else: |
| 184 | + return data |
0 commit comments