tracereports 0.2.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.
- tracereports/__init__.py +31 -0
- tracereports/_env.py +90 -0
- tracereports/client.py +582 -0
- tracereports/context.py +43 -0
- tracereports/dom.py +50 -0
- tracereports/network.py +291 -0
- tracereports/pytest_plugin.py +290 -0
- tracereports/resend.py +63 -0
- tracereports/transport.py +478 -0
- tracereports-0.2.0.dist-info/METADATA +33 -0
- tracereports-0.2.0.dist-info/RECORD +14 -0
- tracereports-0.2.0.dist-info/WHEEL +5 -0
- tracereports-0.2.0.dist-info/entry_points.txt +2 -0
- tracereports-0.2.0.dist-info/top_level.txt +1 -0
tracereports/client.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TraceReports Python client.
|
|
3
|
+
|
|
4
|
+
Zero dependencies (stdlib only). Designed to never break or slow down a test suite:
|
|
5
|
+
|
|
6
|
+
- the evidence (steps, screenshots, network, DOM, test results) is sent by a background thread
|
|
7
|
+
from a bounded queue, so the test does not wait for the network;
|
|
8
|
+
- failed sends are retried with backoff; each one carries an ``Idempotency-Key`` so a retry
|
|
9
|
+
never duplicates a step on the server;
|
|
10
|
+
- if the server does not answer, the calls that need its answer (create run/test) fail fast
|
|
11
|
+
for a while instead of waiting their timeout in every test;
|
|
12
|
+
- ``end_run()`` waits for the queue (``flush_timeout``) and, if something could not be sent,
|
|
13
|
+
saves it to ``spool_dir`` (``$TRACEREPORTS_SPOOL_DIR``) to resend later with
|
|
14
|
+
``python -m tracereports.resend <dir>``, or reports it as lost in ``cr.delivery``.
|
|
15
|
+
|
|
16
|
+
Quick start::
|
|
17
|
+
|
|
18
|
+
from tracereports import TraceReports
|
|
19
|
+
|
|
20
|
+
cr = TraceReports("http://localhost:8080")
|
|
21
|
+
cr.start_run("Regression - Checkout", environment="staging") # project/branch/commit: auto
|
|
22
|
+
|
|
23
|
+
cr.start_test("Login OK", category="smoke,login", description="Valid credentials",
|
|
24
|
+
key="tests/test_login.py::test_login_ok") # stable identity (optional)
|
|
25
|
+
cr.log_info("Open login page")
|
|
26
|
+
cr.attach_screenshot(driver.get_screenshot_as_png(), "Login page") # Selenium
|
|
27
|
+
cr.attach_screenshot(page.screenshot(), "Login page") # Playwright
|
|
28
|
+
cr.log_pass("User logged in")
|
|
29
|
+
cr.end_test() # status derived from steps (or pass status="PASS")
|
|
30
|
+
|
|
31
|
+
cr.end_run() # waits for the queue, then closes the run
|
|
32
|
+
|
|
33
|
+
With unittest / pytest you can let the client manage the test lifecycle::
|
|
34
|
+
|
|
35
|
+
@cr.track(category="smoke")
|
|
36
|
+
def test_login(self): ...
|
|
37
|
+
|
|
38
|
+
with cr.test("Login OK", category="smoke"):
|
|
39
|
+
...
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import contextlib
|
|
45
|
+
import fnmatch
|
|
46
|
+
import functools
|
|
47
|
+
import json
|
|
48
|
+
import logging
|
|
49
|
+
import mimetypes
|
|
50
|
+
import os
|
|
51
|
+
import threading
|
|
52
|
+
import time
|
|
53
|
+
import traceback
|
|
54
|
+
import urllib.error
|
|
55
|
+
import urllib.request
|
|
56
|
+
import uuid
|
|
57
|
+
from typing import Any, Callable, Iterator, Optional, Union
|
|
58
|
+
|
|
59
|
+
from ._env import env
|
|
60
|
+
from .context import detect_branch, detect_commit
|
|
61
|
+
from .transport import Sender
|
|
62
|
+
|
|
63
|
+
__all__ = ["TraceReports"]
|
|
64
|
+
__version__ = "0.2.0"
|
|
65
|
+
|
|
66
|
+
log = logging.getLogger("tracereports")
|
|
67
|
+
|
|
68
|
+
BytesOrPath = Union[bytes, bytearray, str, "os.PathLike[str]"]
|
|
69
|
+
|
|
70
|
+
_SKIP_EXCEPTION_NAMES = {"SkipTest", "Skipped", "SkipException"} # unittest, pytest, others
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _env_float(name: str, default: float) -> float:
|
|
74
|
+
try:
|
|
75
|
+
return float(env(name))
|
|
76
|
+
except ValueError:
|
|
77
|
+
return default
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TraceReports:
|
|
81
|
+
"""Client for the TraceReports server REST API."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
base_url: Optional[str] = None,
|
|
86
|
+
timeout: float = 2.0,
|
|
87
|
+
upload_timeout: float = 5.0,
|
|
88
|
+
enabled: bool = True,
|
|
89
|
+
max_consecutive_failures: int = 3,
|
|
90
|
+
token: Optional[str] = None,
|
|
91
|
+
async_send: Optional[bool] = None,
|
|
92
|
+
spool_dir: Optional[str] = None,
|
|
93
|
+
flush_timeout: Optional[float] = None,
|
|
94
|
+
max_queue_items: int = 5000,
|
|
95
|
+
max_queue_mb: int = 64,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""
|
|
98
|
+
:param base_url: server URL. Defaults to $TRACEREPORTS_URL or http://localhost:8080.
|
|
99
|
+
:param timeout: seconds per JSON request (keep it short so the browser is never blocked).
|
|
100
|
+
:param upload_timeout: seconds per screenshot upload.
|
|
101
|
+
:param enabled: set False (or $TRACEREPORTS_DISABLED=1) to turn every call into a no-op.
|
|
102
|
+
:param max_consecutive_failures: kept for compatibility; an unreachable server now opens a
|
|
103
|
+
30 s circuit (calls fail fast) and the queue keeps retrying instead of disabling.
|
|
104
|
+
:param token: API token (the server's TRACEREPORTS_TOKEN). Defaults to $TRACEREPORTS_TOKEN.
|
|
105
|
+
:param async_send: send the evidence from a background queue (default True; $TRACEREPORTS_SYNC=1
|
|
106
|
+
sends every call inline, useful to debug).
|
|
107
|
+
:param spool_dir: folder where unsent events are saved at end_run (default $TRACEREPORTS_SPOOL_DIR).
|
|
108
|
+
:param flush_timeout: max seconds end_run waits for the queue (default $TRACEREPORTS_FLUSH_TIMEOUT or 30).
|
|
109
|
+
:param max_queue_items / max_queue_mb: queue limits; when full, new events are dropped
|
|
110
|
+
(counted in ``delivery["dropped"]``) and the already queued ones are kept.
|
|
111
|
+
"""
|
|
112
|
+
self.base_url = (base_url or env("URL") or "http://localhost:8080").rstrip("/")
|
|
113
|
+
self.timeout = timeout
|
|
114
|
+
self.upload_timeout = upload_timeout
|
|
115
|
+
self.enabled = enabled and env("DISABLED", "") not in ("1", "true", "yes")
|
|
116
|
+
self.max_consecutive_failures = max_consecutive_failures
|
|
117
|
+
self.token = token or env("TOKEN") or None
|
|
118
|
+
if async_send is None:
|
|
119
|
+
async_send = env("SYNC", "") not in ("1", "true", "yes")
|
|
120
|
+
self.async_send = async_send
|
|
121
|
+
self.flush_timeout = flush_timeout if flush_timeout is not None else _env_float("FLUSH_TIMEOUT", 30.0)
|
|
122
|
+
self._sender = Sender(self.base_url, self._headers, max_items=max_queue_items, max_bytes=max_queue_mb << 20,
|
|
123
|
+
spool_dir=spool_dir or env("SPOOL_DIR") or None)
|
|
124
|
+
|
|
125
|
+
self.run_id: Optional[int] = None
|
|
126
|
+
self.run_created = False # this client created the run (and must close it)
|
|
127
|
+
self.unregistered_tests = 0 # tests whose creation failed: their evidence was not sent
|
|
128
|
+
self._local = threading.local() # current test id per thread
|
|
129
|
+
self._expect: "dict[int, list[dict]]" = {} # test_id -> respuestas esperadas
|
|
130
|
+
self._lock = threading.Lock()
|
|
131
|
+
|
|
132
|
+
# -------------------------------------------------------------- delivery
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def delivery(self) -> dict:
|
|
136
|
+
"""Delivery status: sent, retried, rejected (4xx), dropped (queue full), spooled, lost,
|
|
137
|
+
pending and unregistered_tests."""
|
|
138
|
+
d = dict(self._sender.stats)
|
|
139
|
+
d["pending"] = self._sender.pending
|
|
140
|
+
d["unregistered_tests"] = self.unregistered_tests
|
|
141
|
+
return d
|
|
142
|
+
|
|
143
|
+
def delivery_problems(self) -> int:
|
|
144
|
+
"""Events that did not reach the server (and will not unless resent from the spool)."""
|
|
145
|
+
d = self.delivery
|
|
146
|
+
return d["rejected"] + d["dropped"] + d["lost"] + d["spooled"] + d["pending"] + d["unregistered_tests"]
|
|
147
|
+
|
|
148
|
+
def flush(self, timeout: Optional[float] = None) -> int:
|
|
149
|
+
"""Wait until the queued evidence is sent (max ``timeout`` s, default flush_timeout).
|
|
150
|
+
Returns how many events are still pending."""
|
|
151
|
+
return self._sender.flush(self.flush_timeout if timeout is None else timeout)
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------ runs
|
|
154
|
+
|
|
155
|
+
def start_run(
|
|
156
|
+
self,
|
|
157
|
+
name: str,
|
|
158
|
+
environment: str = "",
|
|
159
|
+
project: Optional[str] = None,
|
|
160
|
+
branch: Optional[str] = None,
|
|
161
|
+
commit: Optional[str] = None,
|
|
162
|
+
framework: str = "",
|
|
163
|
+
detect_context: bool = True,
|
|
164
|
+
) -> Optional[int]:
|
|
165
|
+
"""
|
|
166
|
+
Create a run (suite execution). Returns run_id or None if the server is unreachable.
|
|
167
|
+
|
|
168
|
+
``project`` (default $TRACEREPORTS_PROJECT), ``branch`` and ``commit`` (default: CI variables or
|
|
169
|
+
git) define the context: history, flakiness and comparisons only use runs of the same
|
|
170
|
+
project, environment and branch.
|
|
171
|
+
"""
|
|
172
|
+
if detect_context:
|
|
173
|
+
branch = detect_branch() if branch is None else branch
|
|
174
|
+
commit = detect_commit() if commit is None else commit
|
|
175
|
+
payload = {"name": name, "environment": environment, "project": project or env("PROJECT", ""),
|
|
176
|
+
"branch": branch or "", "commit": commit or "", "framework": framework}
|
|
177
|
+
if self._sender.spool_dir and self.enabled:
|
|
178
|
+
resent = self._sender.load_spool()
|
|
179
|
+
if resent:
|
|
180
|
+
log.info("tracereports: reenviando %d eventos guardados de una ejecución anterior", resent)
|
|
181
|
+
res = self._request("POST", "/api/v1/runs", payload)
|
|
182
|
+
self.run_id = res.get("run_id") if res else None
|
|
183
|
+
self.run_created = bool(self.run_id)
|
|
184
|
+
if self.enabled and not self.run_id:
|
|
185
|
+
log.warning(
|
|
186
|
+
"tracereports: could not create run at %s; is the server running (go run ./cmd)? "
|
|
187
|
+
"Tests will continue without reporting.",
|
|
188
|
+
self.base_url,
|
|
189
|
+
)
|
|
190
|
+
return self.run_id
|
|
191
|
+
|
|
192
|
+
def join_run(self, run_id: Optional[int]) -> Optional[int]:
|
|
193
|
+
"""Report into a run created elsewhere (pytest-xdist controller, CI shards with
|
|
194
|
+
$TRACEREPORTS_RUN_ID). The client that created it is the one that closes it."""
|
|
195
|
+
self.run_id = int(run_id) if run_id else None
|
|
196
|
+
self.run_created = False
|
|
197
|
+
return self.run_id
|
|
198
|
+
|
|
199
|
+
def end_run(self, run_id: Optional[int] = None, interrupted: bool = False) -> Optional[dict]:
|
|
200
|
+
"""
|
|
201
|
+
Wait for the queued evidence (flush_timeout), keep what could not be sent (spool or lost)
|
|
202
|
+
and close the run. ``interrupted=True`` marks it incomplete (Ctrl+C, crashed worker...):
|
|
203
|
+
the server never shows an incomplete run as passed.
|
|
204
|
+
"""
|
|
205
|
+
rid = run_id or self.run_id
|
|
206
|
+
self.flush()
|
|
207
|
+
self._sender.drain_to_spool()
|
|
208
|
+
if not rid:
|
|
209
|
+
return None
|
|
210
|
+
payload = {"interrupted": bool(interrupted)}
|
|
211
|
+
res = self._request("PATCH", f"/api/v1/runs/{rid}/finish", payload)
|
|
212
|
+
if res is None and self.enabled:
|
|
213
|
+
# el servidor no respondió: el cierre también va al spool (se reenvía con lo demás)
|
|
214
|
+
self._sender.enqueue("PATCH", f"/api/v1/runs/{rid}/finish", json.dumps(payload).encode(), "application/json", self.timeout)
|
|
215
|
+
self._sender.drain_to_spool()
|
|
216
|
+
return res
|
|
217
|
+
|
|
218
|
+
def download_report(self, dest_dir: str = ".", run_id: Optional[int] = None, timeout: float = 60.0) -> Optional[str]:
|
|
219
|
+
"""
|
|
220
|
+
Download the run as a self-contained ZIP (open index.html, no server or internet needed) —
|
|
221
|
+
e.g. to attach it to an email from CI. Call after end_run(). Returns the ZIP path or None.
|
|
222
|
+
"""
|
|
223
|
+
rid = run_id or self.run_id
|
|
224
|
+
if not rid or not self.enabled or self._sender.server_down():
|
|
225
|
+
return None
|
|
226
|
+
req = urllib.request.Request(f"{self.base_url}/api/v1/runs/{rid}/export", headers=self._headers())
|
|
227
|
+
try:
|
|
228
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
229
|
+
disposition = resp.headers.get("Content-Disposition", "")
|
|
230
|
+
filename = disposition.split('filename="')[-1].rstrip('"') if 'filename="' in disposition else f"tracereports_run{rid}.zip"
|
|
231
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
232
|
+
path = os.path.join(dest_dir, os.path.basename(filename))
|
|
233
|
+
with open(path, "wb") as fh:
|
|
234
|
+
fh.write(resp.read())
|
|
235
|
+
return path
|
|
236
|
+
except (urllib.error.URLError, OSError) as err:
|
|
237
|
+
log.warning("tracereports: could not download report for run %s: %s", rid, err)
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
# ----------------------------------------------------------------- tests
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def current_test_id(self) -> Optional[int]:
|
|
244
|
+
return getattr(self._local, "test_id", None)
|
|
245
|
+
|
|
246
|
+
def start_test(
|
|
247
|
+
self,
|
|
248
|
+
name: str,
|
|
249
|
+
category: str = "",
|
|
250
|
+
description: str = "",
|
|
251
|
+
key: Optional[str] = None,
|
|
252
|
+
suite: str = "",
|
|
253
|
+
params: str = "",
|
|
254
|
+
worker: Optional[str] = None,
|
|
255
|
+
) -> Optional[int]:
|
|
256
|
+
"""
|
|
257
|
+
Start a test inside the current run. ``category`` accepts comma-separated tags.
|
|
258
|
+
|
|
259
|
+
``key`` is the stable identity of the test inside the project (pytest's nodeid, a
|
|
260
|
+
package + test name...): history, flakiness and comparisons follow it. Without it the
|
|
261
|
+
server uses the name, and two tests with the same name share their history. Avoid keys
|
|
262
|
+
built from random or secret data. ``suite``/``params``/``worker`` are informative.
|
|
263
|
+
"""
|
|
264
|
+
if not self.run_id:
|
|
265
|
+
self._local.test_id = None
|
|
266
|
+
return None
|
|
267
|
+
payload = {"name": name, "category": category, "description": description, "key": key or "",
|
|
268
|
+
"suite": suite, "params": params,
|
|
269
|
+
"worker": worker if worker is not None else os.getenv("PYTEST_XDIST_WORKER", "")}
|
|
270
|
+
res = self._request("POST", f"/api/v1/runs/{self.run_id}/tests", payload)
|
|
271
|
+
self._local.test_id = res.get("test_id") if res else None
|
|
272
|
+
if self.enabled and not self._local.test_id:
|
|
273
|
+
with self._lock:
|
|
274
|
+
self.unregistered_tests += 1
|
|
275
|
+
return self._local.test_id
|
|
276
|
+
|
|
277
|
+
def end_test(
|
|
278
|
+
self,
|
|
279
|
+
status: Optional[str] = None,
|
|
280
|
+
error_message: str = "",
|
|
281
|
+
error_trace: str = "",
|
|
282
|
+
exc: Optional[BaseException] = None,
|
|
283
|
+
test_id: Optional[int] = None,
|
|
284
|
+
attempts: Optional[int] = None,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""
|
|
287
|
+
Close the test. ``status``: PASS | FAIL | WARNING | SKIP, or None to derive it from the logged steps.
|
|
288
|
+
Pass ``exc`` to fill error_message / error_trace from an exception. A FAIL triggers AI triage.
|
|
289
|
+
``attempts``: how many times the runner executed it (retries; >1 shows "passed after retry").
|
|
290
|
+
"""
|
|
291
|
+
tid = test_id or self.current_test_id
|
|
292
|
+
if not tid:
|
|
293
|
+
return None
|
|
294
|
+
if exc is not None:
|
|
295
|
+
error_message = error_message or f"{type(exc).__name__}: {exc}"
|
|
296
|
+
error_trace = error_trace or "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
297
|
+
status = status or "FAIL"
|
|
298
|
+
payload = {"status": (status or "").upper(), "error_message": error_message, "error_trace": error_trace}
|
|
299
|
+
if attempts and attempts > 1:
|
|
300
|
+
payload["attempts"] = int(attempts)
|
|
301
|
+
self._emit("PATCH", f"/api/v1/tests/{tid}/finish", payload)
|
|
302
|
+
self._expect.pop(tid, None)
|
|
303
|
+
if tid == self.current_test_id:
|
|
304
|
+
self._local.test_id = None
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
def expect_response(self, status: Union[int, "list[int]"], url: str = "", method: str = "",
|
|
308
|
+
test_id: Optional[int] = None) -> None:
|
|
309
|
+
"""
|
|
310
|
+
Declare a negative response the test checks on purpose (e.g. a 401 with bad credentials).
|
|
311
|
+
Matching connections are reported as expected: they are not counted as errors nor
|
|
312
|
+
proposed as the cause of a failure. ``url`` is a substring or a glob (``*/api/auth*``).
|
|
313
|
+
"""
|
|
314
|
+
tid = test_id or self.current_test_id
|
|
315
|
+
if not tid:
|
|
316
|
+
return
|
|
317
|
+
statuses = [status] if isinstance(status, int) else list(status)
|
|
318
|
+
self._expect.setdefault(tid, []).append({"status": statuses, "url": url, "method": method.upper()})
|
|
319
|
+
|
|
320
|
+
def _is_expected(self, tid: int, conn: dict) -> bool:
|
|
321
|
+
for rule in self._expect.get(tid, ()):
|
|
322
|
+
if int(conn.get("status") or 0) not in rule["status"]:
|
|
323
|
+
continue
|
|
324
|
+
if rule["method"] and (conn.get("method") or "").upper() != rule["method"]:
|
|
325
|
+
continue
|
|
326
|
+
url = conn.get("url") or ""
|
|
327
|
+
pattern = rule["url"]
|
|
328
|
+
if pattern and not (fnmatch.fnmatch(url, pattern) if any(ch in pattern for ch in "*?[") else pattern in url):
|
|
329
|
+
continue
|
|
330
|
+
return True
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
# ------------------------------------------------------------------ logs
|
|
334
|
+
|
|
335
|
+
def log(self, status: str, message: str, test_id: Optional[int] = None) -> None:
|
|
336
|
+
tid = test_id or self.current_test_id
|
|
337
|
+
if not tid:
|
|
338
|
+
return
|
|
339
|
+
self._emit(
|
|
340
|
+
"POST",
|
|
341
|
+
f"/api/v1/tests/{tid}/logs",
|
|
342
|
+
{"status": status.upper(), "message": str(message), "timestamp": int(time.time() * 1000)},
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def log_info(self, message: str, **kw: Any) -> None:
|
|
346
|
+
self.log("INFO", message, **kw)
|
|
347
|
+
|
|
348
|
+
def log_pass(self, message: str, **kw: Any) -> None:
|
|
349
|
+
self.log("PASS", message, **kw)
|
|
350
|
+
|
|
351
|
+
def log_fail(self, message: str, **kw: Any) -> None:
|
|
352
|
+
self.log("FAIL", message, **kw)
|
|
353
|
+
|
|
354
|
+
def log_warning(self, message: str, **kw: Any) -> None:
|
|
355
|
+
self.log("WARNING", message, **kw)
|
|
356
|
+
|
|
357
|
+
def log_skip(self, message: str, **kw: Any) -> None:
|
|
358
|
+
self.log("SKIP", message, **kw)
|
|
359
|
+
|
|
360
|
+
def attach_screenshot(
|
|
361
|
+
self,
|
|
362
|
+
bytes_or_path: BytesOrPath,
|
|
363
|
+
message: str = "",
|
|
364
|
+
status: str = "INFO",
|
|
365
|
+
test_id: Optional[int] = None,
|
|
366
|
+
wait: bool = False,
|
|
367
|
+
) -> Optional[str]:
|
|
368
|
+
"""
|
|
369
|
+
Upload a screenshot (raw PNG/JPEG bytes or a file path) as a step. It is queued and sent
|
|
370
|
+
in the background; with ``wait=True`` it is sent now and the absolute image URL is returned.
|
|
371
|
+
"""
|
|
372
|
+
tid = test_id or self.current_test_id
|
|
373
|
+
if not tid or not self.enabled:
|
|
374
|
+
return None
|
|
375
|
+
try:
|
|
376
|
+
if isinstance(bytes_or_path, (bytes, bytearray)):
|
|
377
|
+
data, filename = bytes(bytes_or_path), "screenshot.png"
|
|
378
|
+
else:
|
|
379
|
+
path = os.fspath(bytes_or_path)
|
|
380
|
+
with open(path, "rb") as fh:
|
|
381
|
+
data = fh.read()
|
|
382
|
+
filename = os.path.basename(path)
|
|
383
|
+
except OSError as err:
|
|
384
|
+
log.warning("tracereports: cannot read screenshot %r: %s", bytes_or_path, err)
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
body, content_type = _multipart(
|
|
388
|
+
fields={"message": message, "status": status.upper()},
|
|
389
|
+
file_field="file",
|
|
390
|
+
filename=filename,
|
|
391
|
+
data=data,
|
|
392
|
+
)
|
|
393
|
+
path = f"/api/v1/tests/{tid}/screenshot"
|
|
394
|
+
if wait or not self.async_send:
|
|
395
|
+
res = self._sender.send_now("POST", path, body, content_type, self.upload_timeout) if self.enabled else None
|
|
396
|
+
return f"{self.base_url}{res['url']}" if res and res.get("url") else None
|
|
397
|
+
self._sender.enqueue("POST", path, body, content_type, max(self.upload_timeout, 10.0))
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
def attach_network(
|
|
401
|
+
self,
|
|
402
|
+
connections: list,
|
|
403
|
+
test_id: Optional[int] = None,
|
|
404
|
+
max_body_kb: int = 256,
|
|
405
|
+
batch_size: int = 200,
|
|
406
|
+
) -> Optional[dict]:
|
|
407
|
+
"""
|
|
408
|
+
Upload captured network connections (see tracereports.network) to the test's
|
|
409
|
+
"Red" tab. Accepts the dicts produced by the capture (url, method, status, failed,
|
|
410
|
+
wall_time, duration_ms, headers, post_data, response_body, ...). Response bodies are
|
|
411
|
+
cut to ``max_body_kb`` before sending. Connections declared with expect_response() are
|
|
412
|
+
marked as expected. Returns {"stored": n, "errors": n} (queued) or None.
|
|
413
|
+
"""
|
|
414
|
+
tid = test_id or self.current_test_id
|
|
415
|
+
if not tid or not connections or not self.enabled:
|
|
416
|
+
return None
|
|
417
|
+
limit = max_body_kb * 1024
|
|
418
|
+
payload = [_network_payload(c, limit) for c in connections]
|
|
419
|
+
for p in payload:
|
|
420
|
+
p["expected"] = self._is_expected(tid, p)
|
|
421
|
+
errors = sum(1 for p in payload if not p["expected"] and (p["failed"] or p["status"] >= 400))
|
|
422
|
+
for i in range(0, len(payload), batch_size):
|
|
423
|
+
body = json.dumps({"connections": payload[i:i + batch_size]}).encode()
|
|
424
|
+
self._emit_raw("POST", f"/api/v1/tests/{tid}/network", body, "application/json", max(self.upload_timeout, 10.0))
|
|
425
|
+
return {"stored": len(payload), "errors": errors}
|
|
426
|
+
|
|
427
|
+
def attach_dom(self, snapshot: Optional[dict], test_id: Optional[int] = None) -> bool:
|
|
428
|
+
"""
|
|
429
|
+
Sube el snapshot de la página tomado al fallar (ver tracereports.capturar_dom):
|
|
430
|
+
con él la UI recomienda selectores para reemplazar uno roto y marca el elemento en la
|
|
431
|
+
captura. Enviar ANTES de end_test().
|
|
432
|
+
"""
|
|
433
|
+
tid = test_id or self.current_test_id
|
|
434
|
+
if not tid or not snapshot or not self.enabled:
|
|
435
|
+
return False
|
|
436
|
+
body = json.dumps(snapshot).encode()
|
|
437
|
+
return self._emit_raw("POST", f"/api/v1/tests/{tid}/dom", body, "application/json", max(self.upload_timeout, 10.0))
|
|
438
|
+
|
|
439
|
+
# ---------------------------------------------------- framework helpers
|
|
440
|
+
|
|
441
|
+
@contextlib.contextmanager
|
|
442
|
+
def test(
|
|
443
|
+
self,
|
|
444
|
+
name: str,
|
|
445
|
+
category: str = "",
|
|
446
|
+
description: str = "",
|
|
447
|
+
on_failure: Optional[Callable[[], bytes]] = None,
|
|
448
|
+
) -> Iterator["TraceReports"]:
|
|
449
|
+
"""
|
|
450
|
+
Context manager that starts/ends a test and reports exceptions::
|
|
451
|
+
|
|
452
|
+
with cr.test("Checkout", category="e2e", on_failure=driver.get_screenshot_as_png):
|
|
453
|
+
...
|
|
454
|
+
|
|
455
|
+
``on_failure`` (optional) returns screenshot bytes captured when the block fails.
|
|
456
|
+
Exceptions are always re-raised so the test framework still sees them.
|
|
457
|
+
"""
|
|
458
|
+
self.start_test(name, category, description)
|
|
459
|
+
try:
|
|
460
|
+
yield self
|
|
461
|
+
except BaseException as exc: # noqa: BLE001 - re-raised below
|
|
462
|
+
if type(exc).__name__ in _SKIP_EXCEPTION_NAMES:
|
|
463
|
+
self.log_skip(str(exc) or "Skipped")
|
|
464
|
+
self.end_test("SKIP")
|
|
465
|
+
else:
|
|
466
|
+
if on_failure is not None:
|
|
467
|
+
with contextlib.suppress(Exception):
|
|
468
|
+
self.attach_screenshot(on_failure(), "Screenshot at failure", status="FAIL")
|
|
469
|
+
self.log_fail(f"{type(exc).__name__}: {exc}")
|
|
470
|
+
self.end_test(exc=exc)
|
|
471
|
+
raise
|
|
472
|
+
else:
|
|
473
|
+
self.end_test()
|
|
474
|
+
|
|
475
|
+
def track(
|
|
476
|
+
self,
|
|
477
|
+
name: Optional[str] = None,
|
|
478
|
+
category: str = "",
|
|
479
|
+
description: Optional[str] = None,
|
|
480
|
+
on_failure: Optional[Callable[[Any], bytes]] = None,
|
|
481
|
+
) -> Callable:
|
|
482
|
+
"""
|
|
483
|
+
Decorator for unittest/pytest test functions. Uses the docstring as description.
|
|
484
|
+
``on_failure`` receives the first positional argument (``self`` for unittest),
|
|
485
|
+
e.g. ``on_failure=lambda self: self.driver.get_screenshot_as_png()``.
|
|
486
|
+
"""
|
|
487
|
+
|
|
488
|
+
def decorator(fn: Callable) -> Callable:
|
|
489
|
+
@functools.wraps(fn)
|
|
490
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
491
|
+
shot = (lambda: on_failure(args[0] if args else None)) if on_failure else None
|
|
492
|
+
desc = description if description is not None else (fn.__doc__ or "").strip()
|
|
493
|
+
with self.test(name or _pretty(fn.__name__), category, desc, on_failure=shot):
|
|
494
|
+
return fn(*args, **kwargs)
|
|
495
|
+
|
|
496
|
+
return wrapper
|
|
497
|
+
|
|
498
|
+
return decorator
|
|
499
|
+
|
|
500
|
+
# ----------------------------------------------------------------- HTTP
|
|
501
|
+
|
|
502
|
+
def _headers(self, extra: Optional[dict] = None) -> dict:
|
|
503
|
+
headers = {"User-Agent": f"tracereports-py/{__version__}", **(extra or {})}
|
|
504
|
+
if self.token:
|
|
505
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
506
|
+
return headers
|
|
507
|
+
|
|
508
|
+
def _active(self) -> bool:
|
|
509
|
+
return self.enabled
|
|
510
|
+
|
|
511
|
+
def _request(self, method: str, path: str, payload: dict) -> Optional[dict]:
|
|
512
|
+
"""Synchronous call whose answer is needed (run/test ids, closing the run)."""
|
|
513
|
+
if not self.enabled:
|
|
514
|
+
return None
|
|
515
|
+
return self._sender.send_now(method, path, json.dumps(payload).encode(), "application/json", self.timeout)
|
|
516
|
+
|
|
517
|
+
def _emit(self, method: str, path: str, payload: dict) -> bool:
|
|
518
|
+
return self._emit_raw(method, path, json.dumps(payload).encode(), "application/json", max(self.timeout, 5.0))
|
|
519
|
+
|
|
520
|
+
def _emit_raw(self, method: str, path: str, body: bytes, content_type: str, timeout: float) -> bool:
|
|
521
|
+
"""Evidence: queued (default) or sent inline with TRACEREPORTS_SYNC=1."""
|
|
522
|
+
if not self.enabled:
|
|
523
|
+
return False
|
|
524
|
+
if self.async_send:
|
|
525
|
+
return self._sender.enqueue(method, path, body, content_type, timeout)
|
|
526
|
+
return self._sender.send_now(method, path, body, content_type, timeout) is not None
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _network_payload(c: dict, body_limit: int) -> dict:
|
|
530
|
+
"""Map a captured connection to the server's JSON shape, cutting large bodies."""
|
|
531
|
+
body = c.get("response_body") or ""
|
|
532
|
+
if not isinstance(body, str):
|
|
533
|
+
body = str(body)
|
|
534
|
+
wall_time = c.get("wall_time")
|
|
535
|
+
duration = c.get("duration_ms")
|
|
536
|
+
return {
|
|
537
|
+
"method": c.get("method") or "",
|
|
538
|
+
"url": c.get("url") or c.get("response_url") or "",
|
|
539
|
+
"status": int(c.get("status") or 0),
|
|
540
|
+
"status_text": c.get("status_text") or "",
|
|
541
|
+
"mime_type": c.get("mime_type") or "",
|
|
542
|
+
"resource_type": c.get("resource_type") or "",
|
|
543
|
+
"failed": bool(c.get("failed")),
|
|
544
|
+
"error_text": str(c.get("error_text") or ""),
|
|
545
|
+
"started_at": int(wall_time * 1000) if wall_time else 0,
|
|
546
|
+
"duration_ms": int(duration) if duration is not None else None,
|
|
547
|
+
"request_headers": {str(k): str(v) for k, v in (c.get("request_headers") or {}).items()},
|
|
548
|
+
"post_data": str(c.get("post_data") or ""),
|
|
549
|
+
"post_data_via_cdp": bool(c.get("post_data_via_cdp")),
|
|
550
|
+
"response_headers": {str(k): str(v) for k, v in (c.get("response_headers") or {}).items()},
|
|
551
|
+
"response_body": body[:body_limit],
|
|
552
|
+
# the capture may already have cut the body: keep the original size
|
|
553
|
+
"body_size": max(int(c.get("body_size") or 0), len(body)),
|
|
554
|
+
"body_truncated": len(body) > body_limit or int(c.get("body_size") or 0) > len(body),
|
|
555
|
+
# local JSON with the complete capture (reportar_red(..., guardar_en=...))
|
|
556
|
+
"evidence_file": str(c.get("evidence_file") or ""),
|
|
557
|
+
# respuesta negativa esperada por el test (expect_response / marker tracereports_expect)
|
|
558
|
+
"expected": bool(c.get("expected")),
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _pretty(fn_name: str) -> str:
|
|
563
|
+
name = fn_name[5:] if fn_name.startswith("test_") else fn_name
|
|
564
|
+
return name.replace("_", " ").strip().capitalize() or fn_name
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _multipart(fields: dict, file_field: str, filename: str, data: bytes) -> "tuple[bytes, str]":
|
|
568
|
+
boundary = uuid.uuid4().hex
|
|
569
|
+
mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
570
|
+
parts = []
|
|
571
|
+
for key, value in fields.items():
|
|
572
|
+
parts.append(
|
|
573
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"\r\n\r\n{value}\r\n'.encode()
|
|
574
|
+
)
|
|
575
|
+
parts.append(
|
|
576
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; filename="{filename}"\r\n'
|
|
577
|
+
f"Content-Type: {mime}\r\n\r\n".encode()
|
|
578
|
+
+ data
|
|
579
|
+
+ b"\r\n"
|
|
580
|
+
)
|
|
581
|
+
parts.append(f"--{boundary}--\r\n".encode())
|
|
582
|
+
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
|
tracereports/context.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Contexto de la ejecución: rama y commit. El servidor solo compara una ejecución con otras del
|
|
3
|
+
mismo proyecto, ambiente y rama, así que staging no se mezcla con producción ni una rama de
|
|
4
|
+
feature con main.
|
|
5
|
+
|
|
6
|
+
Se lee de las variables de los CI más comunes y, si no hay, de git (si está disponible).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
_BRANCH_VARS = ("TRACEREPORTS_BRANCH", "GITHUB_HEAD_REF", "GITHUB_REF_NAME", "CI_COMMIT_REF_NAME", "BITBUCKET_BRANCH",
|
|
16
|
+
"BUILD_SOURCEBRANCHNAME", "BRANCH_NAME", "CIRCLE_BRANCH", "GIT_BRANCH")
|
|
17
|
+
_COMMIT_VARS = ("TRACEREPORTS_COMMIT", "GITHUB_SHA", "CI_COMMIT_SHA", "BITBUCKET_COMMIT", "BUILD_SOURCEVERSION",
|
|
18
|
+
"CIRCLE_SHA1", "GIT_COMMIT")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _git(*args: str, cwd: Optional[str] = None) -> str:
|
|
22
|
+
try:
|
|
23
|
+
out = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=2)
|
|
24
|
+
return out.stdout.strip() if out.returncode == 0 else ""
|
|
25
|
+
except (OSError, subprocess.SubprocessError):
|
|
26
|
+
return ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def detect_branch(cwd: Optional[str] = None) -> str:
|
|
30
|
+
for var in _BRANCH_VARS:
|
|
31
|
+
value = os.getenv(var, "").strip()
|
|
32
|
+
if value:
|
|
33
|
+
return value[len("origin/"):] if value.startswith("origin/") else value
|
|
34
|
+
branch = _git("rev-parse", "--abbrev-ref", "HEAD", cwd=cwd)
|
|
35
|
+
return "" if branch == "HEAD" else branch
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def detect_commit(cwd: Optional[str] = None) -> str:
|
|
39
|
+
for var in _COMMIT_VARS:
|
|
40
|
+
value = os.getenv(var, "").strip()
|
|
41
|
+
if value:
|
|
42
|
+
return value
|
|
43
|
+
return _git("rev-parse", "HEAD", cwd=cwd)
|
tracereports/dom.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Snapshot de la página al fallar un test: entrada del "AI Locator Recommender".
|
|
3
|
+
|
|
4
|
+
No se guarda el HTML completo, sino los elementos con los que se suele interactuar (inputs,
|
|
5
|
+
botones, links, elementos con rol, test-id, id o name) con sus atributos y su posición en
|
|
6
|
+
pantalla. Con eso el servidor propone selectores robustos para reemplazar el que se rompió y
|
|
7
|
+
la UI dibuja el recuadro del elemento sobre la captura del fallo.
|
|
8
|
+
|
|
9
|
+
from tracereports import capturar_dom
|
|
10
|
+
cr.attach_dom(capturar_dom(page)) # al fallar, ANTES de cr.end_test()
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .network import _mask_sensitive
|
|
14
|
+
|
|
15
|
+
# Corre en el navegador (Playwright: page.evaluate). Máximo 1500 elementos.
|
|
16
|
+
DOM_SCRIPT = """() => {
|
|
17
|
+
const sel = 'a,button,input,select,textarea,label,summary,[role],[data-testid],[data-test-id],[data-test],[id],[name],[aria-label],[placeholder],h1,h2,h3,h4,h5,h6';
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const el of document.querySelectorAll(sel)) {
|
|
20
|
+
if (out.length >= 1500) break;
|
|
21
|
+
const r = el.getBoundingClientRect();
|
|
22
|
+
const st = getComputedStyle(el);
|
|
23
|
+
const visible = r.width > 0 && r.height > 0 && st.visibility !== 'hidden' && st.display !== 'none' && st.opacity !== '0';
|
|
24
|
+
const testAttr = ['data-testid', 'data-test-id', 'data-test'].find((a) => el.hasAttribute(a)) || '';
|
|
25
|
+
const label = el.getAttribute('aria-label') || (el.labels && el.labels[0] ? el.labels[0].innerText : '') || '';
|
|
26
|
+
const text = (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') ? '' : (el.innerText || '').trim().replace(/\\s+/g, ' ');
|
|
27
|
+
out.push({
|
|
28
|
+
tag: el.tagName.toLowerCase(), id: el.id || '', name: el.getAttribute('name') || '',
|
|
29
|
+
role: el.getAttribute('role') || '', testid: testAttr ? el.getAttribute(testAttr) : '', testid_attr: testAttr,
|
|
30
|
+
label: label.trim().slice(0, 120), placeholder: (el.getAttribute('placeholder') || '').slice(0, 120),
|
|
31
|
+
type: el.getAttribute('type') || '', text: text.slice(0, 120),
|
|
32
|
+
x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height), visible,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return { url: location.href, title: document.title, viewport: { w: innerWidth, h: innerHeight }, elements: out };
|
|
36
|
+
}"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def capturar_dom(page):
|
|
40
|
+
"""Snapshot de los elementos de la página (con datos sensibles enmascarados), o None si
|
|
41
|
+
la página ya no está disponible. La evidencia nunca rompe el test."""
|
|
42
|
+
try:
|
|
43
|
+
snap = page.evaluate(DOM_SCRIPT)
|
|
44
|
+
except Exception:
|
|
45
|
+
return None
|
|
46
|
+
for el in snap.get("elements", []):
|
|
47
|
+
for key in ("text", "label", "placeholder"):
|
|
48
|
+
el[key] = _mask_sensitive(el.get(key) or "")
|
|
49
|
+
snap["url"] = _mask_sensitive(snap.get("url") or "")
|
|
50
|
+
return snap
|