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/network.py
ADDED
|
@@ -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())
|