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/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}"
@@ -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