pl-e2e-test 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pl_e2e_test-0.1.0.dist-info/METADATA +322 -0
- pl_e2e_test-0.1.0.dist-info/RECORD +27 -0
- pl_e2e_test-0.1.0.dist-info/WHEEL +5 -0
- pl_e2e_test-0.1.0.dist-info/entry_points.txt +5 -0
- pl_e2e_test-0.1.0.dist-info/licenses/LICENSE +21 -0
- pl_e2e_test-0.1.0.dist-info/top_level.txt +1 -0
- prairielearn_e2e/__init__.py +18 -0
- prairielearn_e2e/__main__.py +3 -0
- prairielearn_e2e/_git.py +167 -0
- prairielearn_e2e/backend.py +168 -0
- prairielearn_e2e/config.json +7 -0
- prairielearn_e2e/config.py +30 -0
- prairielearn_e2e/contract.py +199 -0
- prairielearn_e2e/discovery.py +212 -0
- prairielearn_e2e/errors.py +9 -0
- prairielearn_e2e/fuzz.py +293 -0
- prairielearn_e2e/harness.py +457 -0
- prairielearn_e2e/html_checks.py +133 -0
- prairielearn_e2e/metadata.py +56 -0
- prairielearn_e2e/pytest_plugin.py +335 -0
- prairielearn_e2e/question_tests.py +68 -0
- prairielearn_e2e/regression.py +128 -0
- prairielearn_e2e/reporting.py +675 -0
- prairielearn_e2e/repository.py +298 -0
- prairielearn_e2e/runner.py +28 -0
- prairielearn_e2e/server.py +128 -0
- prairielearn_e2e/sharding.py +46 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
"""Shard report recording, validation, merging, and JUnit output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
import xml.etree.ElementTree as ET
|
|
13
|
+
from collections import Counter, defaultdict
|
|
14
|
+
from collections.abc import Iterable, Sequence
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from importlib.metadata import version
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
from .sharding import Shard
|
|
24
|
+
|
|
25
|
+
REPORT_SCHEMA_VERSION = 1
|
|
26
|
+
REPORT_GLOB = "*.pl-e2e-report.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ReportError(ValueError):
|
|
30
|
+
"""A shard report cannot be safely merged."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _now() -> str:
|
|
34
|
+
return datetime.now(UTC).isoformat()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def collection_fingerprint(nodeids: Sequence[str]) -> str:
|
|
38
|
+
"""Return a stable fingerprint for an ordered pytest collection."""
|
|
39
|
+
|
|
40
|
+
digest = hashlib.sha256()
|
|
41
|
+
for nodeid in nodeids:
|
|
42
|
+
digest.update(nodeid.encode("utf-8"))
|
|
43
|
+
digest.update(b"\0")
|
|
44
|
+
return digest.hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def serialize_pytest_report(
|
|
48
|
+
report: pytest.TestReport | pytest.CollectReport,
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
longrepr = None if report.longrepr is None else str(report.longrepr)
|
|
51
|
+
serialized: dict[str, Any] = {
|
|
52
|
+
"nodeid": report.nodeid,
|
|
53
|
+
"outcome": report.outcome,
|
|
54
|
+
"longrepr": longrepr,
|
|
55
|
+
"sections": [list(section) for section in report.sections],
|
|
56
|
+
}
|
|
57
|
+
when = getattr(report, "when", None)
|
|
58
|
+
if when is not None:
|
|
59
|
+
serialized["when"] = when
|
|
60
|
+
duration = getattr(report, "duration", None)
|
|
61
|
+
if duration is not None:
|
|
62
|
+
serialized["duration"] = duration
|
|
63
|
+
wasxfail = getattr(report, "wasxfail", None)
|
|
64
|
+
if wasxfail is not None:
|
|
65
|
+
serialized["wasxfail"] = str(wasxfail)
|
|
66
|
+
return serialized
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _atomic_json(path: Path, value: dict[str, Any]) -> None:
|
|
70
|
+
path = path.resolve()
|
|
71
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
temporary_name: str | None = None
|
|
73
|
+
try:
|
|
74
|
+
with tempfile.NamedTemporaryFile(
|
|
75
|
+
mode="w",
|
|
76
|
+
encoding="utf-8",
|
|
77
|
+
dir=path.parent,
|
|
78
|
+
prefix=f".{path.name}.",
|
|
79
|
+
suffix=".tmp",
|
|
80
|
+
delete=False,
|
|
81
|
+
) as temporary:
|
|
82
|
+
temporary_name = temporary.name
|
|
83
|
+
json.dump(value, temporary, indent=2, sort_keys=True)
|
|
84
|
+
temporary.write("\n")
|
|
85
|
+
os.replace(temporary_name, path)
|
|
86
|
+
finally:
|
|
87
|
+
if temporary_name is not None:
|
|
88
|
+
try:
|
|
89
|
+
Path(temporary_name).unlink()
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(slots=True)
|
|
95
|
+
class ShardReportRecorder:
|
|
96
|
+
"""Mutable pytest-session state used to create one shard report."""
|
|
97
|
+
|
|
98
|
+
path: Path
|
|
99
|
+
shard: Shard
|
|
100
|
+
fuzz_seed: int | None
|
|
101
|
+
started_at: str = field(default_factory=_now)
|
|
102
|
+
started_monotonic: float = field(default_factory=time.monotonic)
|
|
103
|
+
full_nodeids: list[str] = field(default_factory=list)
|
|
104
|
+
selected_nodeids: list[str] = field(default_factory=list)
|
|
105
|
+
fingerprint: str = ""
|
|
106
|
+
reports: list[dict[str, Any]] = field(default_factory=list)
|
|
107
|
+
collection_errors: list[dict[str, Any]] = field(default_factory=list)
|
|
108
|
+
warnings: list[dict[str, Any]] = field(default_factory=list)
|
|
109
|
+
internal_errors: list[str] = field(default_factory=list)
|
|
110
|
+
interrupted: list[str] = field(default_factory=list)
|
|
111
|
+
intentionally_empty: bool = False
|
|
112
|
+
|
|
113
|
+
def set_collection(
|
|
114
|
+
self, full_nodeids: Sequence[str], selected_nodeids: Sequence[str]
|
|
115
|
+
) -> None:
|
|
116
|
+
self.full_nodeids = list(full_nodeids)
|
|
117
|
+
self.selected_nodeids = list(selected_nodeids)
|
|
118
|
+
self.fingerprint = collection_fingerprint(self.full_nodeids)
|
|
119
|
+
self.intentionally_empty = bool(self.full_nodeids) and not self.selected_nodeids
|
|
120
|
+
|
|
121
|
+
def payload(self, exit_status: int) -> dict[str, Any]:
|
|
122
|
+
return {
|
|
123
|
+
"schema_version": REPORT_SCHEMA_VERSION,
|
|
124
|
+
"producer": {
|
|
125
|
+
"name": "pl-e2e-test",
|
|
126
|
+
"version": version("pl-e2e-test"),
|
|
127
|
+
"pytest_version": pytest.__version__,
|
|
128
|
+
},
|
|
129
|
+
"shard": {"index": self.shard.index, "count": self.shard.count},
|
|
130
|
+
"suite": {
|
|
131
|
+
"fingerprint": self.fingerprint,
|
|
132
|
+
"full_nodeids": self.full_nodeids,
|
|
133
|
+
"selected_nodeids": self.selected_nodeids,
|
|
134
|
+
},
|
|
135
|
+
"fuzz_seed": self.fuzz_seed,
|
|
136
|
+
"started_at": self.started_at,
|
|
137
|
+
"finished_at": _now(),
|
|
138
|
+
"duration": time.monotonic() - self.started_monotonic,
|
|
139
|
+
"exit_status": exit_status,
|
|
140
|
+
"reports": self.reports,
|
|
141
|
+
"collection_errors": self.collection_errors,
|
|
142
|
+
"warnings": self.warnings,
|
|
143
|
+
"internal_errors": self.internal_errors,
|
|
144
|
+
"interrupted": self.interrupted,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def write(self, exit_status: int) -> None:
|
|
148
|
+
_atomic_json(self.path, self.payload(exit_status))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def write_empty_shard_report(
|
|
152
|
+
path: Path, shard: Shard, *, fuzz_seed: int | None = None
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Write a successful report for a diff run that selected no questions."""
|
|
155
|
+
|
|
156
|
+
recorder = ShardReportRecorder(path=path, shard=shard, fuzz_seed=fuzz_seed)
|
|
157
|
+
recorder.set_collection((), ())
|
|
158
|
+
recorder.write(0)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True, slots=True)
|
|
162
|
+
class ItemResult:
|
|
163
|
+
nodeid: str
|
|
164
|
+
outcome: str
|
|
165
|
+
duration: float
|
|
166
|
+
detail: str
|
|
167
|
+
sections: tuple[tuple[str, str], ...]
|
|
168
|
+
reason: str | None = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _expect_dict(value: Any, context: str) -> dict[str, Any]:
|
|
172
|
+
if not isinstance(value, dict):
|
|
173
|
+
raise ReportError(f"{context} must be a JSON object")
|
|
174
|
+
return value
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _expect_list(value: Any, context: str) -> list[Any]:
|
|
178
|
+
if not isinstance(value, list):
|
|
179
|
+
raise ReportError(f"{context} must be a JSON array")
|
|
180
|
+
return value
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _expect_int(value: Any, context: str) -> int:
|
|
184
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
185
|
+
raise ReportError(f"{context} must be an integer")
|
|
186
|
+
return value
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _load_report(path: Path) -> dict[str, Any]:
|
|
190
|
+
try:
|
|
191
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
192
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
193
|
+
raise ReportError(f"unable to read {path}: {exc}") from exc
|
|
194
|
+
report = _expect_dict(raw, os.fspath(path))
|
|
195
|
+
schema_version = report.get("schema_version")
|
|
196
|
+
if (
|
|
197
|
+
isinstance(schema_version, bool)
|
|
198
|
+
or not isinstance(schema_version, int)
|
|
199
|
+
or schema_version != REPORT_SCHEMA_VERSION
|
|
200
|
+
):
|
|
201
|
+
raise ReportError(f"{path}: unsupported report schema {schema_version!r}")
|
|
202
|
+
report["_path"] = os.fspath(path)
|
|
203
|
+
return report
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _report_paths(inputs: Sequence[Path]) -> list[Path]:
|
|
207
|
+
paths: set[Path] = set()
|
|
208
|
+
for input_path in inputs:
|
|
209
|
+
if input_path.is_dir():
|
|
210
|
+
paths.update(path.resolve() for path in input_path.glob(REPORT_GLOB))
|
|
211
|
+
elif input_path.is_file():
|
|
212
|
+
paths.add(input_path.resolve())
|
|
213
|
+
else:
|
|
214
|
+
raise ReportError(f"report input does not exist: {input_path}")
|
|
215
|
+
if not paths:
|
|
216
|
+
raise ReportError("no shard reports found")
|
|
217
|
+
return sorted(paths, key=lambda path: path.as_posix())
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _string_list(value: Any, context: str) -> list[str]:
|
|
221
|
+
values = _expect_list(value, context)
|
|
222
|
+
if not all(isinstance(item, str) for item in values):
|
|
223
|
+
raise ReportError(f"{context} must contain only strings")
|
|
224
|
+
return values
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _validate_reports(reports: Sequence[dict[str, Any]]) -> list[str]:
|
|
228
|
+
first = reports[0]
|
|
229
|
+
first_shard = _expect_dict(first.get("shard"), "shard")
|
|
230
|
+
count = _expect_int(first_shard.get("count"), "shard.count")
|
|
231
|
+
if count < 1:
|
|
232
|
+
raise ReportError("shard.count must be at least 1")
|
|
233
|
+
first_suite = _expect_dict(first.get("suite"), "suite")
|
|
234
|
+
full_nodeids = _string_list(first_suite.get("full_nodeids"), "suite.full_nodeids")
|
|
235
|
+
if len(full_nodeids) != len(set(full_nodeids)):
|
|
236
|
+
raise ReportError("suite.full_nodeids contains duplicates")
|
|
237
|
+
fingerprint = first_suite.get("fingerprint")
|
|
238
|
+
if not isinstance(fingerprint, str) or fingerprint != collection_fingerprint(
|
|
239
|
+
full_nodeids
|
|
240
|
+
):
|
|
241
|
+
raise ReportError("suite fingerprint does not match full_nodeids")
|
|
242
|
+
|
|
243
|
+
producer = _expect_dict(first.get("producer"), "producer")
|
|
244
|
+
if producer.get("name") != "pl-e2e-test" or not all(
|
|
245
|
+
isinstance(producer.get(key), str) for key in ("version", "pytest_version")
|
|
246
|
+
):
|
|
247
|
+
raise ReportError("producer metadata is invalid")
|
|
248
|
+
producer_identity = (producer.get("version"), producer.get("pytest_version"))
|
|
249
|
+
fuzz_seed = first.get("fuzz_seed")
|
|
250
|
+
if fuzz_seed is not None and (
|
|
251
|
+
isinstance(fuzz_seed, bool) or not isinstance(fuzz_seed, int)
|
|
252
|
+
):
|
|
253
|
+
raise ReportError("fuzz_seed must be an integer or null")
|
|
254
|
+
by_index: dict[int, dict[str, Any]] = {}
|
|
255
|
+
selected: set[str] = set()
|
|
256
|
+
for report in reports:
|
|
257
|
+
path = report.get("_path", "report")
|
|
258
|
+
shard = _expect_dict(report.get("shard"), f"{path}: shard")
|
|
259
|
+
index = _expect_int(shard.get("index"), f"{path}: shard.index")
|
|
260
|
+
report_count = _expect_int(shard.get("count"), f"{path}: shard.count")
|
|
261
|
+
if report_count != count:
|
|
262
|
+
raise ReportError(f"{path}: shard count does not match other reports")
|
|
263
|
+
if not 1 <= index <= count:
|
|
264
|
+
raise ReportError(f"{path}: shard index {index} is out of range")
|
|
265
|
+
if index in by_index:
|
|
266
|
+
raise ReportError(f"duplicate report for shard {index}/{count}")
|
|
267
|
+
by_index[index] = report
|
|
268
|
+
|
|
269
|
+
suite = _expect_dict(report.get("suite"), f"{path}: suite")
|
|
270
|
+
if suite.get("fingerprint") != fingerprint:
|
|
271
|
+
raise ReportError(f"{path}: collection fingerprint does not match")
|
|
272
|
+
if (
|
|
273
|
+
_string_list(suite.get("full_nodeids"), f"{path}: suite.full_nodeids")
|
|
274
|
+
!= full_nodeids
|
|
275
|
+
):
|
|
276
|
+
raise ReportError(f"{path}: full collection does not match")
|
|
277
|
+
report_producer = _expect_dict(report.get("producer"), f"{path}: producer")
|
|
278
|
+
if (
|
|
279
|
+
report_producer.get("version"),
|
|
280
|
+
report_producer.get("pytest_version"),
|
|
281
|
+
) != producer_identity:
|
|
282
|
+
raise ReportError(f"{path}: producer versions do not match")
|
|
283
|
+
if report.get("fuzz_seed") != fuzz_seed:
|
|
284
|
+
raise ReportError(f"{path}: fuzz seed does not match")
|
|
285
|
+
|
|
286
|
+
selected_nodeids = _string_list(
|
|
287
|
+
suite.get("selected_nodeids"), f"{path}: suite.selected_nodeids"
|
|
288
|
+
)
|
|
289
|
+
if len(selected_nodeids) != len(set(selected_nodeids)):
|
|
290
|
+
raise ReportError(f"{path}: selected_nodeids contains duplicates")
|
|
291
|
+
duplicates = selected.intersection(selected_nodeids)
|
|
292
|
+
if duplicates:
|
|
293
|
+
raise ReportError(f"{path}: duplicate selected test {min(duplicates)}")
|
|
294
|
+
unknown = set(selected_nodeids).difference(full_nodeids)
|
|
295
|
+
if unknown:
|
|
296
|
+
raise ReportError(f"{path}: selected test is absent from full collection")
|
|
297
|
+
selected.update(selected_nodeids)
|
|
298
|
+
|
|
299
|
+
missing_indices = sorted(set(range(1, count + 1)).difference(by_index))
|
|
300
|
+
if missing_indices:
|
|
301
|
+
raise ReportError(
|
|
302
|
+
"missing shard reports: "
|
|
303
|
+
+ ", ".join(f"{index}/{count}" for index in missing_indices)
|
|
304
|
+
)
|
|
305
|
+
if len(reports) != count:
|
|
306
|
+
raise ReportError(f"expected {count} shard reports, found {len(reports)}")
|
|
307
|
+
if selected != set(full_nodeids):
|
|
308
|
+
missing = set(full_nodeids).difference(selected)
|
|
309
|
+
raise ReportError(
|
|
310
|
+
f"reports do not select the full suite; missing {len(missing)} tests"
|
|
311
|
+
)
|
|
312
|
+
return full_nodeids
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _phases_by_nodeid(
|
|
316
|
+
reports: Sequence[dict[str, Any]],
|
|
317
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
318
|
+
phases: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
319
|
+
for report in reports:
|
|
320
|
+
path = report.get("_path", "report")
|
|
321
|
+
suite = _expect_dict(report.get("suite"), f"{path}: suite")
|
|
322
|
+
selected = set(
|
|
323
|
+
_string_list(
|
|
324
|
+
suite.get("selected_nodeids"), f"{path}: suite.selected_nodeids"
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
for phase in _expect_list(report.get("reports"), f"{path}: reports"):
|
|
328
|
+
phase_dict = _expect_dict(phase, f"{path}: report entry")
|
|
329
|
+
nodeid = phase_dict.get("nodeid")
|
|
330
|
+
if not isinstance(nodeid, str):
|
|
331
|
+
raise ReportError(f"{path}: report nodeid must be a string")
|
|
332
|
+
if nodeid not in selected:
|
|
333
|
+
raise ReportError(f"{path}: result for unselected test {nodeid}")
|
|
334
|
+
phases[nodeid].append(phase_dict)
|
|
335
|
+
for nodeid, node_phases in phases.items():
|
|
336
|
+
whens = [phase.get("when") for phase in node_phases]
|
|
337
|
+
if len(whens) != len(set(whens)):
|
|
338
|
+
raise ReportError(f"duplicate test phase for {nodeid}")
|
|
339
|
+
return phases
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _sections(phases: Iterable[dict[str, Any]]) -> tuple[tuple[str, str], ...]:
|
|
343
|
+
result: list[tuple[str, str]] = []
|
|
344
|
+
for phase in phases:
|
|
345
|
+
for section in _expect_list(phase.get("sections", []), "report sections"):
|
|
346
|
+
if (
|
|
347
|
+
isinstance(section, list)
|
|
348
|
+
and len(section) == 2
|
|
349
|
+
and all(isinstance(value, str) for value in section)
|
|
350
|
+
):
|
|
351
|
+
result.append((section[0], section[1]))
|
|
352
|
+
return tuple(result)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _item_result(nodeid: str, phases: Sequence[dict[str, Any]]) -> ItemResult:
|
|
356
|
+
by_when = {
|
|
357
|
+
phase.get("when"): phase
|
|
358
|
+
for phase in phases
|
|
359
|
+
if phase.get("when") in {"setup", "call", "teardown"}
|
|
360
|
+
}
|
|
361
|
+
duration = sum(
|
|
362
|
+
float(phase.get("duration", 0.0))
|
|
363
|
+
for phase in phases
|
|
364
|
+
if isinstance(phase.get("duration", 0.0), int | float)
|
|
365
|
+
)
|
|
366
|
+
sections = _sections(phases)
|
|
367
|
+
setup = by_when.get("setup")
|
|
368
|
+
call = by_when.get("call")
|
|
369
|
+
teardown = by_when.get("teardown")
|
|
370
|
+
failed_phases = [
|
|
371
|
+
phase
|
|
372
|
+
for phase in (setup, call, teardown)
|
|
373
|
+
if phase is not None and phase.get("outcome") == "failed"
|
|
374
|
+
]
|
|
375
|
+
if failed_phases:
|
|
376
|
+
outcome = (
|
|
377
|
+
"error"
|
|
378
|
+
if any(phase.get("when") != "call" for phase in failed_phases)
|
|
379
|
+
else "failed"
|
|
380
|
+
)
|
|
381
|
+
return ItemResult(
|
|
382
|
+
nodeid,
|
|
383
|
+
outcome,
|
|
384
|
+
duration,
|
|
385
|
+
"\n\n".join(
|
|
386
|
+
str(phase.get("longrepr") or "test failed") for phase in failed_phases
|
|
387
|
+
),
|
|
388
|
+
sections,
|
|
389
|
+
)
|
|
390
|
+
skipped_phase = next(
|
|
391
|
+
(
|
|
392
|
+
phase
|
|
393
|
+
for phase in (setup, call)
|
|
394
|
+
if phase is not None and phase.get("outcome") == "skipped"
|
|
395
|
+
),
|
|
396
|
+
None,
|
|
397
|
+
)
|
|
398
|
+
if skipped_phase is not None:
|
|
399
|
+
wasxfail = skipped_phase.get("wasxfail")
|
|
400
|
+
outcome = "xfailed" if wasxfail is not None else "skipped"
|
|
401
|
+
reason = str(wasxfail or skipped_phase.get("longrepr") or "skipped")
|
|
402
|
+
return ItemResult(nodeid, outcome, duration, "", sections, reason)
|
|
403
|
+
if (
|
|
404
|
+
setup is None
|
|
405
|
+
or setup.get("outcome") != "passed"
|
|
406
|
+
or call is None
|
|
407
|
+
or teardown is None
|
|
408
|
+
):
|
|
409
|
+
return ItemResult(
|
|
410
|
+
nodeid,
|
|
411
|
+
"error",
|
|
412
|
+
duration,
|
|
413
|
+
"test did not complete all setup, call, and teardown phases",
|
|
414
|
+
sections,
|
|
415
|
+
)
|
|
416
|
+
if call.get("wasxfail") is not None:
|
|
417
|
+
return ItemResult(
|
|
418
|
+
nodeid,
|
|
419
|
+
"xpassed",
|
|
420
|
+
duration,
|
|
421
|
+
"",
|
|
422
|
+
sections,
|
|
423
|
+
str(call["wasxfail"]),
|
|
424
|
+
)
|
|
425
|
+
return ItemResult(nodeid, "passed", duration, "", sections)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _session_errors(
|
|
429
|
+
reports: Sequence[dict[str, Any]], item_results: dict[str, ItemResult]
|
|
430
|
+
) -> list[ItemResult]:
|
|
431
|
+
errors: list[ItemResult] = []
|
|
432
|
+
for report in reports:
|
|
433
|
+
shard = _expect_dict(report.get("shard"), "shard")
|
|
434
|
+
label = f"shard-{shard.get('index')}-of-{shard.get('count')}"
|
|
435
|
+
details: list[str] = []
|
|
436
|
+
for collection_error in _expect_list(
|
|
437
|
+
report.get("collection_errors", []), "collection_errors"
|
|
438
|
+
):
|
|
439
|
+
item = _expect_dict(collection_error, "collection error")
|
|
440
|
+
details.append(str(item.get("longrepr") or "collection failed"))
|
|
441
|
+
details.extend(
|
|
442
|
+
str(value)
|
|
443
|
+
for value in _expect_list(
|
|
444
|
+
report.get("internal_errors", []), "internal_errors"
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
details.extend(
|
|
448
|
+
str(value)
|
|
449
|
+
for value in _expect_list(report.get("interrupted", []), "interrupted")
|
|
450
|
+
)
|
|
451
|
+
exit_status = _expect_int(report.get("exit_status"), "exit_status")
|
|
452
|
+
suite = _expect_dict(report.get("suite"), "suite")
|
|
453
|
+
selected_nodeids = _string_list(
|
|
454
|
+
suite.get("selected_nodeids"), "suite.selected_nodeids"
|
|
455
|
+
)
|
|
456
|
+
test_failure_explains_exit = any(
|
|
457
|
+
item_results[nodeid].outcome in {"failed", "error"}
|
|
458
|
+
for nodeid in selected_nodeids
|
|
459
|
+
)
|
|
460
|
+
if exit_status != 0 and not details and not test_failure_explains_exit:
|
|
461
|
+
details.append(f"pytest exited with status {exit_status}")
|
|
462
|
+
if details:
|
|
463
|
+
errors.append(
|
|
464
|
+
ItemResult(
|
|
465
|
+
nodeid=f"prairielearn_e2e::{label}",
|
|
466
|
+
outcome="error",
|
|
467
|
+
duration=0.0,
|
|
468
|
+
detail="\n\n".join(details),
|
|
469
|
+
sections=(),
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
return errors
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _summary(results: Sequence[ItemResult]) -> str:
|
|
476
|
+
counts = Counter(result.outcome for result in results)
|
|
477
|
+
order = ("failed", "error", "passed", "skipped", "xfailed", "xpassed")
|
|
478
|
+
parts = [f"{counts[name]} {name}" for name in order if counts[name]]
|
|
479
|
+
return ", ".join(parts) if parts else "no tests ran"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _print_terminal(results: Sequence[ItemResult], warnings: Sequence[str]) -> None:
|
|
483
|
+
for result in results:
|
|
484
|
+
if result.outcome not in {"failed", "error"}:
|
|
485
|
+
continue
|
|
486
|
+
print(f"{'=' * 20} {result.outcome.upper()} {result.nodeid} {'=' * 20}")
|
|
487
|
+
print(result.detail)
|
|
488
|
+
for heading, content in result.sections:
|
|
489
|
+
print(f"-------------------- {heading} --------------------")
|
|
490
|
+
print(content)
|
|
491
|
+
if warnings:
|
|
492
|
+
print("==================== warnings summary ====================")
|
|
493
|
+
for warning in warnings:
|
|
494
|
+
print(warning)
|
|
495
|
+
noteworthy = [
|
|
496
|
+
result
|
|
497
|
+
for result in results
|
|
498
|
+
if result.outcome in {"failed", "error", "skipped", "xfailed", "xpassed"}
|
|
499
|
+
]
|
|
500
|
+
if noteworthy:
|
|
501
|
+
print("==================== short test summary info ====================")
|
|
502
|
+
for result in noteworthy:
|
|
503
|
+
print(f"{result.outcome.upper():8} {result.nodeid}")
|
|
504
|
+
duration = sum(result.duration for result in results)
|
|
505
|
+
print(
|
|
506
|
+
f"==================== {_summary(results)} in {duration:.2f}s "
|
|
507
|
+
"===================="
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _failure_message(detail: str, fallback: str) -> str:
|
|
512
|
+
lines = [line.strip() for line in detail.splitlines() if line.strip()]
|
|
513
|
+
return lines[-1] if lines else fallback
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _xml_safe(value: str) -> str:
|
|
517
|
+
return "".join(
|
|
518
|
+
character
|
|
519
|
+
if (
|
|
520
|
+
character in "\t\n\r"
|
|
521
|
+
or 0x20 <= ord(character) <= 0xD7FF
|
|
522
|
+
or 0xE000 <= ord(character) <= 0xFFFD
|
|
523
|
+
or 0x10000 <= ord(character) <= 0x10FFFF
|
|
524
|
+
)
|
|
525
|
+
else f"#x{ord(character):02X}"
|
|
526
|
+
for character in value
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _write_junit(path: Path, results: Sequence[ItemResult]) -> None:
|
|
531
|
+
counts = Counter(result.outcome for result in results)
|
|
532
|
+
failures = counts["failed"]
|
|
533
|
+
errors = counts["error"]
|
|
534
|
+
skipped = counts["skipped"] + counts["xfailed"]
|
|
535
|
+
duration = sum(result.duration for result in results)
|
|
536
|
+
testsuites = ET.Element(
|
|
537
|
+
"testsuites",
|
|
538
|
+
{
|
|
539
|
+
"name": "pl-e2e-test",
|
|
540
|
+
"tests": str(len(results)),
|
|
541
|
+
"failures": str(failures),
|
|
542
|
+
"errors": str(errors),
|
|
543
|
+
"skipped": str(skipped),
|
|
544
|
+
"time": f"{duration:.6f}",
|
|
545
|
+
},
|
|
546
|
+
)
|
|
547
|
+
testsuite = ET.SubElement(
|
|
548
|
+
testsuites,
|
|
549
|
+
"testsuite",
|
|
550
|
+
{
|
|
551
|
+
"name": "pl-e2e-test",
|
|
552
|
+
"tests": str(len(results)),
|
|
553
|
+
"failures": str(failures),
|
|
554
|
+
"errors": str(errors),
|
|
555
|
+
"skipped": str(skipped),
|
|
556
|
+
"time": f"{duration:.6f}",
|
|
557
|
+
},
|
|
558
|
+
)
|
|
559
|
+
for result in results:
|
|
560
|
+
testcase = ET.SubElement(
|
|
561
|
+
testsuite,
|
|
562
|
+
"testcase",
|
|
563
|
+
{
|
|
564
|
+
"classname": "prairielearn_e2e",
|
|
565
|
+
"name": _xml_safe(result.nodeid),
|
|
566
|
+
"time": f"{result.duration:.6f}",
|
|
567
|
+
},
|
|
568
|
+
)
|
|
569
|
+
if result.outcome == "failed":
|
|
570
|
+
failure = ET.SubElement(
|
|
571
|
+
testcase,
|
|
572
|
+
"failure",
|
|
573
|
+
{"message": _xml_safe(_failure_message(result.detail, "test failed"))},
|
|
574
|
+
)
|
|
575
|
+
failure.text = _xml_safe(result.detail)
|
|
576
|
+
elif result.outcome == "error":
|
|
577
|
+
error = ET.SubElement(
|
|
578
|
+
testcase,
|
|
579
|
+
"error",
|
|
580
|
+
{"message": _xml_safe(_failure_message(result.detail, "test error"))},
|
|
581
|
+
)
|
|
582
|
+
error.text = _xml_safe(result.detail)
|
|
583
|
+
elif result.outcome in {"skipped", "xfailed"}:
|
|
584
|
+
ET.SubElement(
|
|
585
|
+
testcase,
|
|
586
|
+
"skipped",
|
|
587
|
+
{"message": _xml_safe(result.reason or "skipped")},
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
stdout = "\n".join(
|
|
591
|
+
content
|
|
592
|
+
for heading, content in result.sections
|
|
593
|
+
if "stdout" in heading.lower()
|
|
594
|
+
)
|
|
595
|
+
stderr = "\n".join(
|
|
596
|
+
content
|
|
597
|
+
for heading, content in result.sections
|
|
598
|
+
if "stderr" in heading.lower() or "log" in heading.lower()
|
|
599
|
+
)
|
|
600
|
+
if stdout:
|
|
601
|
+
ET.SubElement(testcase, "system-out").text = _xml_safe(stdout)
|
|
602
|
+
if stderr:
|
|
603
|
+
ET.SubElement(testcase, "system-err").text = _xml_safe(stderr)
|
|
604
|
+
|
|
605
|
+
ET.indent(testsuites)
|
|
606
|
+
path = path.resolve()
|
|
607
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
608
|
+
temporary_name: str | None = None
|
|
609
|
+
try:
|
|
610
|
+
with tempfile.NamedTemporaryFile(
|
|
611
|
+
mode="wb",
|
|
612
|
+
dir=path.parent,
|
|
613
|
+
prefix=f".{path.name}.",
|
|
614
|
+
suffix=".tmp",
|
|
615
|
+
delete=False,
|
|
616
|
+
) as temporary:
|
|
617
|
+
temporary_name = temporary.name
|
|
618
|
+
ET.ElementTree(testsuites).write(
|
|
619
|
+
temporary, encoding="utf-8", xml_declaration=True
|
|
620
|
+
)
|
|
621
|
+
os.replace(temporary_name, path)
|
|
622
|
+
finally:
|
|
623
|
+
if temporary_name is not None:
|
|
624
|
+
try:
|
|
625
|
+
Path(temporary_name).unlink()
|
|
626
|
+
except FileNotFoundError:
|
|
627
|
+
pass
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def merge_reports(inputs: Sequence[Path], junitxml: Path) -> int:
|
|
631
|
+
"""Validate and merge shard reports, returning a process exit status."""
|
|
632
|
+
|
|
633
|
+
report_paths = _report_paths(inputs)
|
|
634
|
+
if junitxml.resolve() in report_paths:
|
|
635
|
+
raise ReportError("JUnit output path must not overwrite an input report")
|
|
636
|
+
try:
|
|
637
|
+
junitxml.unlink(missing_ok=True)
|
|
638
|
+
except OSError as exc:
|
|
639
|
+
raise ReportError(f"unable to replace {junitxml}: {exc}") from exc
|
|
640
|
+
reports = [_load_report(path) for path in report_paths]
|
|
641
|
+
full_nodeids = _validate_reports(reports)
|
|
642
|
+
phases = _phases_by_nodeid(reports)
|
|
643
|
+
results = [_item_result(nodeid, phases.get(nodeid, ())) for nodeid in full_nodeids]
|
|
644
|
+
results.extend(
|
|
645
|
+
_session_errors(reports, {result.nodeid: result for result in results})
|
|
646
|
+
)
|
|
647
|
+
warnings = sorted(
|
|
648
|
+
{
|
|
649
|
+
str(warning.get("message", ""))
|
|
650
|
+
for report in reports
|
|
651
|
+
for warning in _expect_list(report.get("warnings", []), "warnings")
|
|
652
|
+
if isinstance(warning, dict) and warning.get("message")
|
|
653
|
+
}
|
|
654
|
+
)
|
|
655
|
+
_print_terminal(results, warnings)
|
|
656
|
+
_write_junit(junitxml, results)
|
|
657
|
+
return int(any(result.outcome in {"failed", "error"} for result in results))
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
661
|
+
parser = argparse.ArgumentParser(
|
|
662
|
+
description="Merge pl-e2e-test shard reports into one terminal and JUnit report."
|
|
663
|
+
)
|
|
664
|
+
parser.add_argument("reports", nargs="+", type=Path)
|
|
665
|
+
parser.add_argument("--junitxml", type=Path, default=Path("pl-e2e-results.xml"))
|
|
666
|
+
args = parser.parse_args(argv)
|
|
667
|
+
try:
|
|
668
|
+
return merge_reports(args.reports, args.junitxml)
|
|
669
|
+
except (OSError, ReportError) as exc:
|
|
670
|
+
print(f"pl-e2e-merge-reports: error: {exc}", file=sys.stderr)
|
|
671
|
+
return 2
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
if __name__ == "__main__":
|
|
675
|
+
raise SystemExit(main())
|