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.
@@ -0,0 +1,291 @@
1
+ """
2
+ Captura de red (backend) con Playwright para TraceReports.
3
+
4
+ Adaptado de Utils/network_capture.py del framework de automatización:
5
+ - mismos nombres y mismo estado en el page (page._network_evidence), así que
6
+ guardar_evidencia_red() del framework sigue funcionando encima de esta captura;
7
+ - mismo enmascarado de datos sensibles (RUT, tokens, passwords, cookies...);
8
+ - mismo fallback CDP para recuperar el post_data de ciertos POST.
9
+
10
+ Novedades:
11
+ - duration_ms y resource_type por conexión;
12
+ - reportar_red(page, cr): envía a TraceReports SOLO las conexiones nuevas desde el
13
+ último envío (por test, no acumulado) y espera brevemente las que siguen en vuelo.
14
+
15
+ Uso:
16
+ page = context.new_page()
17
+ attach_listeners(page, logger=logger) # ANTES del primer page.goto()
18
+ ...
19
+ resumen = reportar_red(page, cr, guardar_en="output/network", nombre_evento="test_001_login")
20
+ # -> {"total": 42, "ok": 40, "errores": 2, "fallidas": 1, "archivo": "C:/.../network_test_001_login_1790.json"}
21
+ """
22
+
23
+ import json
24
+ import os
25
+ import re
26
+ import time
27
+
28
+ _SENSITIVE_HEADER_NAMES = {"authorization", "cookie", "set-cookie", "x-auth-token"}
29
+ _BODY_RESOURCE_TYPES = {"xhr", "fetch"}
30
+
31
+
32
+ def _mask_sensitive(text):
33
+ if not isinstance(text, str):
34
+ return text
35
+ text = re.sub(r'\b\d{7,8}-[\dkK]\b', '<rut>', text)
36
+ text = re.sub(
37
+ r'"(token|sig|session|auth|password|pwd|clave)"\s*:\s*"[^"]*"',
38
+ r'"\1": "<masked>"',
39
+ text,
40
+ flags=re.IGNORECASE,
41
+ )
42
+ text = re.sub(
43
+ r'\b(token|sig|session|auth|password|pwd|clave)=[^&\s"]+',
44
+ r'\1=<masked>',
45
+ text,
46
+ flags=re.IGNORECASE,
47
+ )
48
+ return text
49
+
50
+
51
+ def _mask_headers(headers):
52
+ if not headers:
53
+ return {}
54
+ return {
55
+ key: "<masked>" if key.lower() in _SENSITIVE_HEADER_NAMES else _mask_sensitive(value)
56
+ for key, value in headers.items()
57
+ }
58
+
59
+
60
+ def attach_listeners(
61
+ page,
62
+ logger=None,
63
+ incluir_bodies_api=True,
64
+ incluir_bodies_error=True,
65
+ patrones_api=("/api/",),
66
+ max_body_chars=None,
67
+ ):
68
+ """
69
+ Registra los listeners de red en el page. Debe llamarse INMEDIATAMENTE después de
70
+ crear el page, antes de cualquier page.goto() (Playwright no tiene buffer retroactivo).
71
+
72
+ El body de la response se lee en el momento en que llega (después el navegador puede
73
+ descartarlo). Se guarda el body de: URLs que contienen algún `patrones_api`, requests
74
+ XHR/fetch, y respuestas con status >= 400. Por defecto el body se conserva COMPLETO
75
+ (como network_capture.py) para que reportar_red(..., guardar_en=...) lo deje entero en
76
+ disco; `max_body_chars` permite acotarlo si la memoria importa más que la evidencia.
77
+ """
78
+ page._network_evidence = []
79
+ page._network_by_request = {}
80
+ page._network_reported_idx = 0
81
+ page._network_postdata_cdp_pendientes = {}
82
+ page._network_conexiones_esperando_postdata = []
83
+
84
+ def _on_request(request):
85
+ try:
86
+ conexion = {
87
+ "requestId": id(request),
88
+ "url": _mask_sensitive(request.url),
89
+ "method": request.method,
90
+ "resource_type": request.resource_type,
91
+ "request_headers": _mask_headers(request.headers),
92
+ "post_data": _mask_sensitive(request.post_data or ""),
93
+ "has_post_data": bool(request.post_data),
94
+ "wall_time": time.time(),
95
+ }
96
+ page._network_evidence.append(conexion)
97
+ page._network_by_request[request] = conexion
98
+
99
+ if not conexion["has_post_data"]:
100
+ clave = (request.method, request.url)
101
+ cola = page._network_postdata_cdp_pendientes.get(clave)
102
+ if cola:
103
+ conexion["post_data"] = _mask_sensitive(cola.pop(0))
104
+ conexion["has_post_data"] = True
105
+ conexion["post_data_via_cdp"] = True
106
+ else:
107
+ page._network_conexiones_esperando_postdata.append((request.method, request.url, conexion))
108
+ except Exception as e:
109
+ if logger:
110
+ logger.debug(f"No se pudo registrar request: {e}")
111
+
112
+ def _on_response(response):
113
+ try:
114
+ conexion = page._network_by_request.get(response.request)
115
+ if conexion is None:
116
+ return
117
+ conexion["status"] = response.status
118
+ conexion["status_text"] = response.status_text
119
+ conexion["mime_type"] = (response.headers or {}).get("content-type", "")
120
+ conexion["response_headers"] = _mask_headers(response.headers)
121
+ conexion["response_url"] = _mask_sensitive(response.url)
122
+ except Exception as e:
123
+ if logger:
124
+ logger.debug(f"No se pudo registrar response: {e}")
125
+ return
126
+
127
+ url = (conexion.get("url") or "").lower()
128
+ es_api = any(p in url for p in patrones_api) or conexion.get("resource_type") in _BODY_RESOURCE_TYPES
129
+ if (incluir_bodies_api and es_api) or (incluir_bodies_error and response.status >= 400):
130
+ try:
131
+ body = response.text()
132
+ conexion["body_size"] = len(body)
133
+ conexion["response_body"] = _mask_sensitive(body if max_body_chars is None else body[:max_body_chars])
134
+ except Exception as e:
135
+ if logger:
136
+ logger.debug(f"No se pudo leer el body de {conexion.get('url')}: {e}")
137
+
138
+ def _duracion(request, conexion):
139
+ try:
140
+ timing = request.timing or {}
141
+ if timing.get("responseEnd", -1) > 0:
142
+ return int(timing["responseEnd"])
143
+ except Exception:
144
+ pass
145
+ return int((time.time() - conexion["wall_time"]) * 1000)
146
+
147
+ def _on_request_finished(request):
148
+ conexion = page._network_by_request.get(request)
149
+ if conexion is not None:
150
+ conexion["duration_ms"] = _duracion(request, conexion)
151
+
152
+ def _on_request_failed(request):
153
+ try:
154
+ conexion = page._network_by_request.get(request)
155
+ if conexion is None:
156
+ return
157
+ conexion["failed"] = True
158
+ conexion["error_text"] = request.failure
159
+ conexion["duration_ms"] = int((time.time() - conexion["wall_time"]) * 1000)
160
+ except Exception as e:
161
+ if logger:
162
+ logger.debug(f"No se pudo registrar requestfailed: {e}")
163
+
164
+ page.on("request", _on_request)
165
+ page.on("response", _on_response)
166
+ page.on("requestfinished", _on_request_finished)
167
+ page.on("requestfailed", _on_request_failed)
168
+
169
+ # Fallback CDP para post_data (solo Chromium): ver network_capture.py original.
170
+ try:
171
+ cdp = page.context.new_cdp_session(page)
172
+ cdp.send("Network.enable")
173
+
174
+ def _on_cdp_request_will_be_sent(params):
175
+ try:
176
+ req = params.get("request") or {}
177
+ if not req.get("hasPostData") or req.get("postData"):
178
+ return
179
+ request_id = params.get("requestId")
180
+ if not request_id:
181
+ return
182
+ post_data_cdp = cdp.send("Network.getRequestPostData", {"requestId": request_id}).get("postData")
183
+ if not post_data_cdp:
184
+ return
185
+ metodo, url = req.get("method"), req.get("url")
186
+ pendientes = page._network_conexiones_esperando_postdata
187
+ for i, (m, u, conexion) in enumerate(pendientes):
188
+ if m == metodo and u == url:
189
+ conexion["post_data"] = _mask_sensitive(post_data_cdp)
190
+ conexion["has_post_data"] = True
191
+ conexion["post_data_via_cdp"] = True
192
+ pendientes.pop(i)
193
+ return
194
+ page._network_postdata_cdp_pendientes.setdefault((metodo, url), []).append(post_data_cdp)
195
+ except Exception as e:
196
+ if logger:
197
+ logger.debug(f"No se pudo obtener postData via CDP: {e}")
198
+
199
+ cdp.on("Network.requestWillBeSent", _on_cdp_request_will_be_sent)
200
+ except Exception as e:
201
+ if logger:
202
+ logger.debug(f"Sin sesión CDP para el fallback de postData (¿navegador no Chromium?): {e}")
203
+
204
+
205
+ def conexiones_del_test(page):
206
+ """Conexiones capturadas desde el último reportar_red(): las del test en curso.
207
+ Útil para validar el backend dentro del test (p. ej. "se envió el POST de login")."""
208
+ return list(getattr(page, "_network_evidence", [])[getattr(page, "_network_reported_idx", 0):])
209
+
210
+
211
+ def esperar_conexion(page, predicado, timeout_ms=5000):
212
+ """Espera hasta que una conexión del test cumpla `predicado` y tenga respuesta (o falle).
213
+ Retorna la conexión o None."""
214
+ limite = time.time() + timeout_ms / 1000
215
+ while True:
216
+ for c in conexiones_del_test(page):
217
+ if predicado(c) and (c.get("status") or c.get("failed")):
218
+ return c
219
+ if time.time() >= limite:
220
+ return None
221
+ page.wait_for_timeout(100)
222
+
223
+
224
+ def _en_vuelo(conexiones):
225
+ return [c for c in conexiones if not c.get("status") and not c.get("failed")]
226
+
227
+
228
+ def resumir_red(conexiones):
229
+ """{"total", "ok", "errores", "fallidas"} — errores = HTTP >= 400 o sin respuesta."""
230
+ fallidas = sum(1 for c in conexiones if c.get("failed"))
231
+ http_error = sum(1 for c in conexiones if not c.get("failed") and (c.get("status") or 0) >= 400)
232
+ return {
233
+ "total": len(conexiones),
234
+ "ok": sum(1 for c in conexiones if not c.get("failed") and 0 < (c.get("status") or 0) < 400),
235
+ "errores": fallidas + http_error,
236
+ "fallidas": fallidas,
237
+ }
238
+
239
+
240
+ def guardar_red_local(conexiones, carpeta, nombre_evento):
241
+ """Guarda las conexiones COMPLETAS (sin recortar bodies) en carpeta/network_<evento>_<ts>.json
242
+ y retorna la ruta absoluta (o None si no se pudo escribir)."""
243
+ os.makedirs(carpeta, exist_ok=True)
244
+ nombre = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in nombre_evento)
245
+ ruta = os.path.abspath(os.path.join(carpeta, f"network_{nombre}_{int(time.time())}.json"))
246
+ with open(ruta, "w", encoding="utf-8") as fh:
247
+ json.dump(conexiones, fh, ensure_ascii=False, indent=2, default=str)
248
+ return ruta
249
+
250
+
251
+ def reportar_red(page, cr, test_id=None, esperar_en_vuelo_ms=2000, logger=None, guardar_en=None, nombre_evento=None):
252
+ """
253
+ Envía a TraceReports las conexiones capturadas desde el último envío (las del test
254
+ actual) y retorna su resumen. Antes espera hasta `esperar_en_vuelo_ms` a que terminen
255
+ las requests todavía sin respuesta, para no reportarlas incompletas.
256
+
257
+ guardar_en: carpeta donde dejar además un JSON con la captura COMPLETA del test. El
258
+ servidor guarda como máximo 256 KB por body; con esto el reporte indica la ruta exacta
259
+ del archivo que tiene el body entero (p. ej. catálogos de varios MB).
260
+ """
261
+ capturadas = getattr(page, "_network_evidence", None)
262
+ if capturadas is None:
263
+ if logger:
264
+ logger.warning("reportar_red: el page no tiene attach_listeners() — no hay captura de red")
265
+ return None
266
+
267
+ desde = getattr(page, "_network_reported_idx", 0)
268
+ limite = time.time() + esperar_en_vuelo_ms / 1000
269
+ while _en_vuelo(capturadas[desde:]) and time.time() < limite:
270
+ try:
271
+ page.wait_for_timeout(100) # deja que Playwright procese los eventos pendientes
272
+ except Exception:
273
+ break
274
+
275
+ nuevas = capturadas[desde:]
276
+ page._network_reported_idx = desde + len(nuevas)
277
+ resumen = resumir_red(nuevas)
278
+ if nuevas and guardar_en:
279
+ try:
280
+ ruta = guardar_red_local(nuevas, guardar_en, nombre_evento or "test")
281
+ for conexion in nuevas:
282
+ conexion["evidence_file"] = ruta
283
+ resumen["archivo"] = ruta
284
+ except OSError as e:
285
+ if logger:
286
+ logger.warning(f"No se pudo guardar la evidencia de red local: {e}")
287
+ if nuevas:
288
+ cr.attach_network(nuevas, test_id=test_id)
289
+ if logger:
290
+ logger.info(f"Red reportada: {resumen}")
291
+ return resumen
@@ -0,0 +1,290 @@
1
+ """
2
+ Plugin de pytest: reporta la suite a TraceReports sin cambiar el código de los tests.
3
+
4
+ pytest --tracereports # servidor en $TRACEREPORTS_URL o localhost:8080
5
+ pytest --tracereports --tracereports-run "Regresión" --tracereports-env staging
6
+ pytest --tracereports -n 4 # pytest-xdist: un solo reporte consolidado
7
+
8
+ Qué hace:
9
+ - una ejecución (run) por sesión de pytest y un test por cada caso. Con pytest-xdist el
10
+ controlador crea la ejecución y los workers reportan en ella; se cierra cuando terminan
11
+ todos (si un worker se cae, sus tests quedan como interrumpidos y la ejecución, incompleta);
12
+ - identidad estable de cada test: su nodeid (archivo::clase::test[parámetros]), aparte del
13
+ nombre visible. Dos tests con el mismo nombre en archivos distintos no mezclan su historial.
14
+ @pytest.mark.tracereports_id("login-ok") la fija a mano si el nodeid cambia;
15
+ - contexto: proyecto (--tracereports-project / $TRACEREPORTS_PROJECT / la carpeta), rama y commit (del CI
16
+ o de git). El historial y las comparaciones solo usan ejecuciones del mismo contexto;
17
+ - estado PASS/FAIL/SKIP con el mensaje y el traceback del fallo (entrada del AI Triage). Con
18
+ pytest-rerunfailures, los intentos quedan en el mismo test ("pasó tras reintento") y la
19
+ evidencia del primer fallo se conserva;
20
+ - con pytest-playwright (fixture `page`): captura de pantalla al fallar, snapshot del DOM y
21
+ captura de red del test (pestaña "Red"), sin configurar nada;
22
+ - @pytest.mark.tracereports_expect(status=401, url="/api/auth") declara una respuesta negativa que
23
+ el test verifica a propósito (también en la clase o el módulo): no cuenta como error ni como
24
+ causa del fallo;
25
+ - fixture `tracereports` para agregar pasos y capturas propias:
26
+
27
+ def test_login(page, tracereports):
28
+ tracereports.log_info("Abrir login")
29
+ tracereports.attach_screenshot(page.screenshot(), "Formulario de login")
30
+
31
+ La evidencia se envía en segundo plano con reintentos; al terminar, el resumen de la terminal
32
+ dice si algo no llegó. --tracereports-strict hace fallar la sesión en ese caso (útil en CI).
33
+
34
+ Configuración por variables de entorno equivalentes: TRACEREPORTS_URL, TRACEREPORTS_TOKEN, TRACEREPORTS_RUN_NAME,
35
+ TRACEREPORTS_ENV, TRACEREPORTS_PROJECT, TRACEREPORTS_RUN_ID (unirse a una ejecución ya creada, p. ej. shards de
36
+ CI), TRACEREPORTS_SPOOL_DIR, TRACEREPORTS_STRICT. Con --tracereports-zip DIR el reporte queda además en un ZIP
37
+ (se abre sin servidor ni internet), ideal para adjuntarlo como artefacto del pipeline.
38
+ """
39
+
40
+ import os
41
+
42
+ import pytest
43
+
44
+ from ._env import env
45
+ from .client import TraceReports
46
+ from .dom import capturar_dom
47
+ from .network import attach_listeners, reportar_red
48
+
49
+ _TEST_ID = pytest.StashKey[int]()
50
+ _OUTCOME = pytest.StashKey[dict]()
51
+ _PAGE = pytest.StashKey[object]()
52
+
53
+
54
+ def _page(item):
55
+ """Página de pytest-playwright del test (pytest borra item.funcargs tras el teardown)."""
56
+ page = item.stash.get(_PAGE, None)
57
+ if page is None:
58
+ page = (getattr(item, "funcargs", None) or {}).get("page")
59
+ return page
60
+
61
+
62
+ def _truthy(value):
63
+ return str(value or "").lower() in ("1", "true", "yes")
64
+
65
+
66
+ def pytest_addoption(parser):
67
+ group = parser.getgroup("tracereports", "TraceReports")
68
+ group.addoption("--tracereports", action="store_true", help="reportar la ejecución a TraceReports")
69
+ group.addoption("--tracereports-url", default=None, help="URL del servidor (default: $TRACEREPORTS_URL o http://localhost:8080)")
70
+ group.addoption("--tracereports-run", default=None, help="nombre de la ejecución (default: $TRACEREPORTS_RUN_NAME o la carpeta del proyecto)")
71
+ group.addoption("--tracereports-env", default=None, help="ambiente: navegador, entorno, versión... (default: $TRACEREPORTS_ENV)")
72
+ group.addoption("--tracereports-project", default=None, help="proyecto (default: $TRACEREPORTS_PROJECT o la carpeta raíz)")
73
+ group.addoption("--tracereports-no-network", action="store_true", help="no capturar la red del backend con Playwright")
74
+ group.addoption("--tracereports-no-screenshots", action="store_true", help="no tomar captura de pantalla al fallar (datos sensibles en pantalla)")
75
+ group.addoption("--tracereports-no-dom", action="store_true", help="no enviar el snapshot del DOM al fallar")
76
+ group.addoption("--tracereports-zip", default=None, metavar="DIR", help="al terminar, guardar el reporte en ZIP en DIR (útil como artefacto de CI)")
77
+ group.addoption("--tracereports-spool", default=None, metavar="DIR", help="carpeta donde guardar la evidencia que no se pudo enviar (default: $TRACEREPORTS_SPOOL_DIR)")
78
+ group.addoption("--tracereports-strict", action="store_true", help="falla la sesión si parte de la evidencia no llegó al servidor (default: $TRACEREPORTS_STRICT)")
79
+
80
+
81
+ def pytest_configure(config):
82
+ config.addinivalue_line("markers", "tracereports_id(key): identidad estable del test en TraceReports (reemplaza el nodeid)")
83
+ config.addinivalue_line("markers", "tracereports_expect(status, url='', method=''): respuesta HTTP negativa esperada por el test")
84
+ if config.getoption("--tracereports"):
85
+ config.pluginmanager.register(TraceReportsPlugin(config), "tracereports-plugin")
86
+
87
+
88
+ @pytest.fixture
89
+ def tracereports(request):
90
+ """Cliente de TraceReports ligado al test en curso (no-op si no se usa --tracereports)."""
91
+ plugin = request.config.pluginmanager.get_plugin("tracereports-plugin")
92
+ return plugin.cr if plugin else TraceReports(enabled=False)
93
+
94
+
95
+ def _identity(item):
96
+ """(key, suite, params) del test: nodeid salvo que @tracereports_id lo fije."""
97
+ marker = item.get_closest_marker("tracereports_id")
98
+ key = str(marker.args[0]) if marker and marker.args else item.nodeid
99
+ suite = item.nodeid.rsplit("::", 1)[0] if "::" in item.nodeid else item.nodeid
100
+ callspec = getattr(item, "callspec", None)
101
+ params = getattr(callspec, "id", "") if callspec is not None else ""
102
+ return key, suite, params
103
+
104
+
105
+ class TraceReportsPlugin:
106
+ def __init__(self, config):
107
+ self.config = config
108
+ self.cr = TraceReports(config.getoption("--tracereports-url"), spool_dir=config.getoption("--tracereports-spool"))
109
+ self.capture_network = not config.getoption("--tracereports-no-network")
110
+ self.capture_screenshots = not config.getoption("--tracereports-no-screenshots")
111
+ self.capture_dom = not config.getoption("--tracereports-no-dom")
112
+ self.strict = config.getoption("--tracereports-strict") or _truthy(env("STRICT"))
113
+ self.workerinput = getattr(config, "workerinput", None) # presente en workers de xdist
114
+ self.crashed_workers = []
115
+ self.worker_problems = 0
116
+ self.problems = []
117
+
118
+ # ─── sesión ────────────────────────────────────────────────────────────
119
+ def _ensure_run(self):
120
+ """Crea (o se une a) la ejecución una sola vez. En un worker de xdist usa la del controlador."""
121
+ if self.cr.run_id:
122
+ return
123
+ if self.workerinput is not None:
124
+ self.cr.join_run(self.workerinput.get("tracereports_run_id"))
125
+ return
126
+ shared = env("RUN_ID")
127
+ if shared:
128
+ self.cr.join_run(shared)
129
+ return
130
+ root = str(self.config.rootpath)
131
+ name = self.config.getoption("--tracereports-run") or env("RUN_NAME") or os.path.basename(root)
132
+ environment = self.config.getoption("--tracereports-env") or env("ENV") or ""
133
+ project = self.config.getoption("--tracereports-project") or env("PROJECT") or os.path.basename(root)
134
+ self.cr.start_run(name, environment=environment, project=project, framework="pytest")
135
+
136
+ def pytest_sessionstart(self, session):
137
+ self._ensure_run()
138
+
139
+ @pytest.hookimpl(optionalhook=True)
140
+ def pytest_configure_node(self, node):
141
+ """xdist (controlador): todos los workers reportan en la misma ejecución."""
142
+ self._ensure_run()
143
+ node.workerinput["tracereports_run_id"] = self.cr.run_id
144
+
145
+ @pytest.hookimpl(optionalhook=True)
146
+ def pytest_testnodedown(self, node, error):
147
+ """xdist (controlador): un worker terminó; si se cayó, la ejecución queda incompleta."""
148
+ if error:
149
+ self.crashed_workers.append(getattr(node, "gateway", None) and node.gateway.id or "worker")
150
+ out = getattr(node, "workeroutput", None) or {}
151
+ self.worker_problems += int(out.get("tracereports_problems", 0))
152
+
153
+ def pytest_sessionfinish(self, session, exitstatus):
154
+ if self.workerinput is not None:
155
+ # worker: entrega su evidencia; el controlador cierra la ejecución
156
+ self.cr.flush()
157
+ self.cr._sender.drain_to_spool()
158
+ self.config.workeroutput["tracereports_problems"] = self.cr.delivery_problems()
159
+ return
160
+ if not self.cr.run_id:
161
+ if self.cr.enabled:
162
+ self.problems.append("no se pudo crear la ejecución en el servidor")
163
+ self._apply_strict(session)
164
+ return
165
+ if self.cr.run_created:
166
+ interrupted = bool(self.crashed_workers) or exitstatus == pytest.ExitCode.INTERRUPTED or bool(session.shouldstop)
167
+ self.cr.end_run(interrupted=interrupted)
168
+ zip_dir = self.config.getoption("--tracereports-zip")
169
+ if zip_dir:
170
+ self.zip_path = self.cr.download_report(zip_dir)
171
+ else:
172
+ self.cr.flush()
173
+ self.cr._sender.drain_to_spool()
174
+ lost = self.cr.delivery_problems() + self.worker_problems
175
+ if lost:
176
+ self.problems.append(f"{lost} eventos de evidencia no llegaron al servidor")
177
+ if self.crashed_workers:
178
+ self.problems.append(f"workers caídos: {', '.join(map(str, self.crashed_workers))} (ejecución incompleta)")
179
+ self._apply_strict(session)
180
+
181
+ def _apply_strict(self, session):
182
+ if self.strict and self.problems and session.exitstatus == pytest.ExitCode.OK:
183
+ session.exitstatus = pytest.ExitCode.TESTS_FAILED
184
+
185
+ def pytest_terminal_summary(self, terminalreporter):
186
+ if self.workerinput is not None:
187
+ return
188
+ if self.cr.run_id:
189
+ terminalreporter.write_line(f"TraceReports: {self.cr.base_url}/#run={self.cr.run_id}&view=dashboard")
190
+ if getattr(self, "zip_path", None):
191
+ terminalreporter.write_line(f"TraceReports ZIP: {self.zip_path}")
192
+ d = self.cr.delivery
193
+ if self.problems:
194
+ detail = ", ".join(f"{k}={d[k]}" for k in ("sent", "retried", "rejected", "dropped", "spooled", "lost", "unregistered_tests") if d[k])
195
+ terminalreporter.write_line(f"TraceReports: atención: {'; '.join(self.problems)} ({detail})"
196
+ + (" — --tracereports-strict: la sesión falla" if self.strict else ""),
197
+ yellow=True)
198
+
199
+ # ─── cada test ─────────────────────────────────────────────────────────
200
+ @pytest.hookimpl(hookwrapper=True)
201
+ def pytest_runtest_protocol(self, item, nextitem):
202
+ doc = (getattr(item.function, "__doc__", None) or "").strip() if hasattr(item, "function") else ""
203
+ markers = [m.name for m in item.iter_markers()
204
+ if m.name not in ("parametrize", "usefixtures", "skip", "skipif", "xfail", "tracereports_id", "tracereports_expect",
205
+ "flaky")]
206
+ category = ", ".join([item.module.__name__.split(".")[-1], *markers]) if hasattr(item, "module") else ", ".join(markers)
207
+ key, suite, params = _identity(item)
208
+ test_id = self.cr.start_test(item.name, category=category, description=doc.splitlines()[0] if doc else "",
209
+ key=key, suite=suite, params=params)
210
+ if test_id:
211
+ item.stash[_TEST_ID] = test_id
212
+ for m in item.iter_markers("tracereports_expect"):
213
+ status = m.kwargs.get("status", m.args[0] if m.args else None)
214
+ if status is not None:
215
+ self.cr.expect_response(status, url=m.kwargs.get("url", ""), method=m.kwargs.get("method", ""), test_id=test_id)
216
+ item.stash[_OUTCOME] = {"status": None, "message": "", "trace": "", "attempts": 0}
217
+ yield
218
+ self._finish(item)
219
+
220
+ @pytest.hookimpl(hookwrapper=True)
221
+ def pytest_runtest_call(self, item):
222
+ # La captura de red se engancha antes de que el test navegue (la fixture page ya existe).
223
+ page = (getattr(item, "funcargs", None) or {}).get("page")
224
+ if page is not None:
225
+ item.stash[_PAGE] = page
226
+ if self.capture_network and not hasattr(page, "_network_evidence"):
227
+ attach_listeners(page)
228
+ yield
229
+ # Con la página todavía abierta: esperar (máx. 2 s) las llamadas en vuelo y enviar la red.
230
+ # Después del teardown pytest-playwright cierra la página y ya no se puede esperar.
231
+ test_id = item.stash.get(_TEST_ID, None)
232
+ if page is not None and test_id and hasattr(page, "_network_evidence"):
233
+ reportar_red(page, self.cr, test_id=test_id, esperar_en_vuelo_ms=2000)
234
+
235
+ @pytest.hookimpl(hookwrapper=True)
236
+ def pytest_runtest_makereport(self, item, call):
237
+ report = (yield).get_result()
238
+ outcome = item.stash.get(_OUTCOME, None)
239
+ if outcome is None:
240
+ return
241
+ if report.when == "setup":
242
+ # cada intento empieza con su setup (pytest-rerunfailures repite setup/call/teardown)
243
+ outcome["attempts"] += 1
244
+ if outcome["attempts"] > 1:
245
+ previous = outcome["message"] or "falló"
246
+ test_id = item.stash.get(_TEST_ID, None)
247
+ self.cr.log_warning(f"Reintento {outcome['attempts']}: el intento {outcome['attempts'] - 1} falló — {previous}", test_id=test_id)
248
+ outcome.update(status=None, message="", trace="")
249
+ if report.skipped:
250
+ reason = report.longrepr[2] if isinstance(report.longrepr, tuple) else str(report.longrepr or "")
251
+ if outcome["status"] is None:
252
+ outcome.update(status="SKIP", message=reason.replace("Skipped: ", ""))
253
+ elif report.failed:
254
+ if outcome["status"] != "FAIL":
255
+ text = report.longreprtext or ""
256
+ last = [ln for ln in text.splitlines() if ln.startswith("E ")]
257
+ outcome.update(status="FAIL", message=(" ".join(ln[2:].strip() for ln in last[:3]) or text[:300]).strip(), trace=text)
258
+ if report.when != "call":
259
+ outcome["message"] = f"Error en {report.when}: " + outcome["message"]
260
+ self._evidence_on_failure(item)
261
+ elif report.when == "call" and outcome["status"] is None:
262
+ outcome["status"] = "PASS"
263
+
264
+ def _evidence_on_failure(self, item):
265
+ page = _page(item)
266
+ if page is None:
267
+ return
268
+ test_id = item.stash.get(_TEST_ID, None)
269
+ if self.capture_screenshots:
270
+ try:
271
+ self.cr.attach_screenshot(page.screenshot(timeout=5000), "Captura al fallar", status="FAIL", test_id=test_id)
272
+ except Exception:
273
+ pass # la página puede estar cerrada: la evidencia nunca rompe el test
274
+ if self.capture_dom:
275
+ # snapshot de los elementos: el reporte recomienda selectores si el fallo fue de locator
276
+ self.cr.attach_dom(capturar_dom(page), test_id=test_id)
277
+
278
+ def _finish(self, item):
279
+ test_id = item.stash.get(_TEST_ID, None)
280
+ if not test_id:
281
+ return
282
+ outcome = item.stash[_OUTCOME]
283
+ page = _page(item)
284
+ if page is not None and hasattr(page, "_network_evidence"):
285
+ # lo que llegó durante el teardown (la página ya puede estar cerrada: sin esperar)
286
+ reportar_red(page, self.cr, test_id=test_id, esperar_en_vuelo_ms=0)
287
+ if outcome["status"] == "FAIL":
288
+ self.cr.log_fail(outcome["message"] or "Falló", test_id=test_id)
289
+ self.cr.end_test(outcome["status"] or "PASS", error_message=outcome["message"] if outcome["status"] == "FAIL" else "",
290
+ error_trace=outcome["trace"], test_id=test_id, attempts=outcome["attempts"])
tracereports/resend.py ADDED
@@ -0,0 +1,63 @@
1
+ """
2
+ Reenvía la evidencia que quedó en la cola local (spool) porque el servidor no respondía:
3
+
4
+ python -m tracereports.resend ./tracereports-spool
5
+ python -m tracereports.resend ./tracereports-spool --url http://tracereports:8080 --timeout 120
6
+
7
+ Cada evento lleva su clave de idempotencia: reenviarlo dos veces no duplica nada. Un spool más
8
+ grande que la cola se reenvía por tandas. Lo que no se pueda enviar ahora vuelve a la misma carpeta.
9
+
10
+ Código de salida: 0 solo si no queda ningún evento pendiente en la carpeta; 1 si queda evidencia
11
+ (sin enviar, en archivos que otro proceso está reenviando, o ilegibles) o el servidor rechazó algo.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import logging
18
+ import sys
19
+ import time
20
+
21
+ from .client import TraceReports
22
+ from .transport import spool_files
23
+
24
+
25
+ def main(argv=None) -> int:
26
+ parser = argparse.ArgumentParser(prog="python -m tracereports.resend", description=__doc__,
27
+ formatter_class=argparse.RawDescriptionHelpFormatter)
28
+ parser.add_argument("spool_dir", help="carpeta del spool (TRACEREPORTS_SPOOL_DIR)")
29
+ parser.add_argument("--url", default=None, help="URL del servidor (default: $TRACEREPORTS_URL o http://localhost:8080)")
30
+ parser.add_argument("--timeout", type=float, default=120.0, help="segundos máximos reenviando")
31
+ args = parser.parse_args(argv)
32
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
33
+
34
+ if not any(spool_files(args.spool_dir).values()):
35
+ print(f"No hay eventos pendientes en {args.spool_dir}.")
36
+ return 0
37
+
38
+ cr = TraceReports(args.url, spool_dir=args.spool_dir)
39
+ sender = cr._sender
40
+ deadline = time.time() + args.timeout
41
+ loaded = pending = 0
42
+ while time.time() < deadline: # por tandas: el spool puede ser más grande que la cola
43
+ n = sender.load_spool()
44
+ loaded += n
45
+ pending = cr.flush(max(deadline - time.time(), 0))
46
+ if n == 0 or pending:
47
+ break
48
+ sender.drain_to_spool() # lo que no salió vuelve al spool
49
+ d = cr.delivery
50
+ left = spool_files(args.spool_dir)
51
+ print(f"Reenviados {d['sent']} de {loaded} eventos cargados · rechazados por el servidor {d['rejected']} · "
52
+ f"vuelven al spool {d['spooled']}.")
53
+ if left["pending"]:
54
+ print(f"Quedan {left['pending']} archivos sin reenviar en {args.spool_dir} (el servidor no respondió o se acabó el tiempo).")
55
+ if left["claimed"]:
56
+ print(f"{left['claimed']} archivos los está reenviando otro proceso (o quedaron de uno que terminó: se retoman solos).")
57
+ if left["unreadable"]:
58
+ print(f"{left['unreadable']} archivos ilegibles quedaron como *.unreadable: revísalos a mano.")
59
+ return 0 if not any(left.values()) and d["rejected"] == 0 else 1
60
+
61
+
62
+ if __name__ == "__main__":
63
+ sys.exit(main())