tracereports 0.2.0__tar.gz

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,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: tracereports
3
+ Version: 0.2.0
4
+ Summary: TraceReports client: reports test steps, screenshots and browser network to a TraceReports server. Includes a pytest plugin.
5
+ Author: Jose Lopez
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/josemiguellopez/tracereports
8
+ Project-URL: Documentation, https://github.com/josemiguellopez/tracereports/blob/main/docs/en/python.md
9
+ Project-URL: Issues, https://github.com/josemiguellopez/tracereports/issues
10
+ Keywords: testing,test-reporting,pytest,playwright,selenium,e2e,qa
11
+ Classifier: Framework :: Pytest
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Topic :: Software Development :: Testing
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ Provides-Extra: pytest
18
+ Requires-Dist: pytest>=7; extra == "pytest"
19
+ Provides-Extra: playwright
20
+ Requires-Dist: playwright>=1.40; extra == "playwright"
21
+
22
+ # tracereports (Python client)
23
+
24
+ 🌐 **English** · [Español](README.es.md)
25
+
26
+ Client for the [TraceReports](../../README.md) server. Standard library only.
27
+
28
+ ```bash
29
+ pip install ./client/python # from the repository root
30
+ pytest --tracereports # reports your pytest suite with no code changes
31
+ ```
32
+
33
+ Full documentation: [docs/en/python.md](../../docs/en/python.md).
@@ -0,0 +1,12 @@
1
+ # tracereports (Python client)
2
+
3
+ 🌐 **English** · [Español](README.es.md)
4
+
5
+ Client for the [TraceReports](../../README.md) server. Standard library only.
6
+
7
+ ```bash
8
+ pip install ./client/python # from the repository root
9
+ pytest --tracereports # reports your pytest suite with no code changes
10
+ ```
11
+
12
+ Full documentation: [docs/en/python.md](../../docs/en/python.md).
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tracereports"
7
+ version = "0.2.0"
8
+ description = "TraceReports client: reports test steps, screenshots and browser network to a TraceReports server. Includes a pytest plugin."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "Apache-2.0"}
12
+ authors = [{name = "Jose Lopez"}]
13
+ keywords = ["testing", "test-reporting", "pytest", "playwright", "selenium", "e2e", "qa"]
14
+ classifiers = [
15
+ "Framework :: Pytest",
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Topic :: Software Development :: Testing",
19
+ ]
20
+ dependencies = [] # solo librería estándar
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/josemiguellopez/tracereports"
24
+ Documentation = "https://github.com/josemiguellopez/tracereports/blob/main/docs/en/python.md"
25
+ Issues = "https://github.com/josemiguellopez/tracereports/issues"
26
+
27
+ [project.optional-dependencies]
28
+ pytest = ["pytest>=7"]
29
+ playwright = ["playwright>=1.40"]
30
+
31
+ [project.entry-points.pytest11]
32
+ tracereports = "tracereports.pytest_plugin"
33
+
34
+ [tool.setuptools]
35
+ packages = ["tracereports"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,107 @@
1
+ """El cliente lee TRACEREPORTS_* del .env del proyecto cuando no están en el entorno."""
2
+
3
+ import json
4
+ import os
5
+ import threading
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+
8
+ import pytest
9
+
10
+ from tracereports import TraceReports
11
+ from tracereports import _env
12
+
13
+
14
+ @pytest.fixture
15
+ def project(tmp_path, monkeypatch):
16
+ """Un repositorio con .env en la raíz y los tests en una subcarpeta (cwd)."""
17
+ (tmp_path / ".git").mkdir()
18
+ sub = tmp_path / "tests" / "e2e"
19
+ sub.mkdir(parents=True)
20
+ monkeypatch.chdir(sub)
21
+ monkeypatch.delenv("TRACEREPORTS_ENV_FILE", raising=False)
22
+ for k in ("TRACEREPORTS_TOKEN", "TRACEREPORTS_URL"):
23
+ monkeypatch.delenv(k, raising=False)
24
+ _env.reset_env_file()
25
+ yield tmp_path
26
+ _env.reset_env_file()
27
+
28
+
29
+ def test_same_rules_as_the_server(tmp_path):
30
+ f = tmp_path / ".env"
31
+ f.write_text(
32
+ "# comentario\n\nexport TRACEREPORTS_TOKEN=abc\nTRACEREPORTS_URL = 'http://x:1' \n"
33
+ 'TRACEREPORTS_RUN_NAME="con # dentro"\nTRACEREPORTS_ENV=qa # comentario al final\n'
34
+ "MALA LINEA=1\nsin_igual\n",
35
+ encoding="utf-8",
36
+ )
37
+ got = _env.parse_env_file(str(f))
38
+ assert got == {"TRACEREPORTS_TOKEN": "abc", "TRACEREPORTS_URL": "http://x:1",
39
+ "TRACEREPORTS_RUN_NAME": "con # dentro", "TRACEREPORTS_ENV": "qa"}
40
+
41
+
42
+ def test_found_walking_up_to_the_repository_root(project, monkeypatch):
43
+ (project / ".env").write_text("TRACEREPORTS_TOKEN=desde-env\nAI_API_KEY=no-se-usa\n", encoding="utf-8")
44
+ assert _env.env("TOKEN") == "desde-env"
45
+ assert _env.env_file_in_use() == str(project / ".env")
46
+ assert "AI_API_KEY" not in _env._file() # solo claves del cliente
47
+
48
+
49
+ def test_environment_wins_over_the_file(project, monkeypatch):
50
+ (project / ".env").write_text("TRACEREPORTS_TOKEN=archivo\n", encoding="utf-8")
51
+ monkeypatch.setenv("TRACEREPORTS_TOKEN", "secret-de-ci")
52
+ assert _env.env("TOKEN") == "secret-de-ci"
53
+ monkeypatch.delenv("TRACEREPORTS_TOKEN")
54
+ assert _env.env("TOKEN") == "archivo"
55
+
56
+
57
+ def test_does_not_leave_the_repository(project):
58
+ # un .env por encima de la raíz del repositorio (p. ej. en la carpeta del usuario) no se usa
59
+ (project.parent / ".env").write_text("TRACEREPORTS_TOKEN=ajeno\n", encoding="utf-8")
60
+ try:
61
+ assert _env.env("TOKEN") == ""
62
+ assert _env.env_file_in_use() is None
63
+ finally:
64
+ (project.parent / ".env").unlink()
65
+
66
+
67
+ def test_explicit_file_and_off(project, monkeypatch, tmp_path):
68
+ (project / ".env").write_text("TRACEREPORTS_TOKEN=raiz\n", encoding="utf-8")
69
+ other = tmp_path / "otro.env"
70
+ other.write_text("TRACEREPORTS_TOKEN=otro\n", encoding="utf-8")
71
+ monkeypatch.setenv("TRACEREPORTS_ENV_FILE", str(other))
72
+ _env.reset_env_file()
73
+ assert _env.env("TOKEN") == "otro"
74
+ monkeypatch.setenv("TRACEREPORTS_ENV_FILE", "off")
75
+ _env.reset_env_file()
76
+ assert _env.env("TOKEN") == ""
77
+
78
+
79
+ def test_the_client_sends_the_token_from_the_env_file(project):
80
+ seen = []
81
+
82
+ class Handler(BaseHTTPRequestHandler):
83
+ def log_message(self, *a):
84
+ pass
85
+
86
+ def do_POST(self):
87
+ self.rfile.read(int(self.headers.get("Content-Length") or 0))
88
+ seen.append(self.headers.get("Authorization"))
89
+ raw = json.dumps({"run_id": 1}).encode()
90
+ self.send_response(201)
91
+ self.send_header("Content-Type", "application/json")
92
+ self.send_header("Content-Length", str(len(raw)))
93
+ self.end_headers()
94
+ self.wfile.write(raw)
95
+
96
+ httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
97
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
98
+ try:
99
+ (project / ".env").write_text(
100
+ f"TRACEREPORTS_URL=http://127.0.0.1:{httpd.server_address[1]}\nTRACEREPORTS_TOKEN=tok-del-env\n",
101
+ encoding="utf-8")
102
+ cr = TraceReports(async_send=False)
103
+ assert cr.start_run("r") == 1
104
+ assert seen == ["Bearer tok-del-env"]
105
+ finally:
106
+ httpd.shutdown()
107
+ httpd.server_close()
@@ -0,0 +1,430 @@
1
+ """Pruebas del transporte del cliente: reintentos, idempotencia, caídas, cola llena y spool.
2
+
3
+ pip install pytest && pytest client/python/tests
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import socket
9
+ import threading
10
+ import time
11
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12
+
13
+ import pytest
14
+
15
+ from tracereports import TraceReports
16
+ from tracereports.transport import Sender
17
+
18
+
19
+ class FakeServer:
20
+ """Servidor de TraceReports mínimo: guarda lo que recibe y puede fallar a propósito."""
21
+
22
+ def __init__(self, port=0, fail_first=0):
23
+ self.requests = [] # (method, path, idempotency key, body)
24
+ self.fail_first = fail_first
25
+ self.lock = threading.Lock()
26
+ outer = self
27
+
28
+ class Handler(BaseHTTPRequestHandler):
29
+ def log_message(self, *a):
30
+ pass
31
+
32
+ def _handle(self):
33
+ body = self.rfile.read(int(self.headers.get("Content-Length") or 0))
34
+ with outer.lock:
35
+ if outer.fail_first > 0:
36
+ outer.fail_first -= 1
37
+ self.send_response(503)
38
+ self.end_headers()
39
+ return
40
+ outer.requests.append((self.command, self.path, self.headers.get("Idempotency-Key"), body))
41
+ out = {}
42
+ if self.path == "/api/v1/runs":
43
+ out = {"run_id": 7}
44
+ elif self.path.endswith("/tests"):
45
+ out = {"test_id": 11}
46
+ raw = json.dumps(out).encode()
47
+ self.send_response(201)
48
+ self.send_header("Content-Type", "application/json")
49
+ self.send_header("Content-Length", str(len(raw)))
50
+ self.end_headers()
51
+ self.wfile.write(raw)
52
+
53
+ do_POST = do_PATCH = _handle
54
+
55
+ self.httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
56
+ self.port = self.httpd.server_address[1]
57
+ threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
58
+
59
+ @property
60
+ def url(self):
61
+ return f"http://127.0.0.1:{self.port}"
62
+
63
+ def messages(self):
64
+ return [json.loads(b)["message"] for m, p, k, b in self.requests if p.endswith("/logs")]
65
+
66
+ def close(self):
67
+ self.httpd.shutdown()
68
+ self.httpd.server_close()
69
+
70
+
71
+ def free_port():
72
+ s = socket.socket()
73
+ s.bind(("127.0.0.1", 0))
74
+ port = s.getsockname()[1]
75
+ s.close()
76
+ return port
77
+
78
+
79
+ @pytest.fixture(autouse=True)
80
+ def fast_backoff(monkeypatch):
81
+ import tracereports.transport as tr
82
+ monkeypatch.setattr(tr, "MAX_BACKOFF", 0.2)
83
+ monkeypatch.setattr(tr, "CIRCUIT_SECONDS", 0.5)
84
+ for var in ("TRACEREPORTS_SPOOL_DIR", "TRACEREPORTS_SYNC", "TRACEREPORTS_DISABLED", "TRACEREPORTS_TOKEN"):
85
+ monkeypatch.delenv(var, raising=False)
86
+
87
+
88
+ def test_retries_keep_order_and_idempotency_key():
89
+ srv = FakeServer(fail_first=3)
90
+ try:
91
+ cr = TraceReports(srv.url)
92
+ cr.run_id = 7
93
+ for i in range(20):
94
+ cr.log_info(f"paso {i}", test_id=11)
95
+ assert cr.flush(10) == 0
96
+ assert srv.messages() == [f"paso {i}" for i in range(20)], "no evidence lost, order kept"
97
+ keys = [k for m, p, k, b in srv.requests]
98
+ assert len(set(keys)) == 20 and all(keys), "one idempotency key per event"
99
+ assert cr.delivery["retried"] >= 1 and cr.delivery["sent"] == 20
100
+ finally:
101
+ srv.close()
102
+
103
+
104
+ def test_server_down_then_up_delivers_everything():
105
+ port = free_port()
106
+ cr = TraceReports(f"http://127.0.0.1:{port}", timeout=0.3)
107
+ cr.run_id = 7
108
+ t0 = time.perf_counter()
109
+ for i in range(50):
110
+ cr.log_info(f"paso {i}", test_id=11)
111
+ elapsed = time.perf_counter() - t0
112
+ assert elapsed < 0.5, f"queuing must not block the test while the server is down ({elapsed:.2f}s)"
113
+ time.sleep(0.5)
114
+ srv = FakeServer(port=port) # el servidor vuelve
115
+ try:
116
+ assert cr.flush(15) == 0
117
+ assert srv.messages() == [f"paso {i}" for i in range(50)]
118
+ finally:
119
+ srv.close()
120
+
121
+
122
+ def test_full_queue_drops_new_events_and_reports_it():
123
+ port = free_port()
124
+ cr = TraceReports(f"http://127.0.0.1:{port}", timeout=0.2, max_queue_items=5)
125
+ cr.run_id = 7
126
+ for i in range(12):
127
+ cr.log_info(f"paso {i}", test_id=11)
128
+ d = cr.delivery
129
+ assert d["dropped"] >= 6 and d["pending"] <= 5
130
+ assert cr.delivery_problems() > 0
131
+
132
+
133
+ def test_unsent_events_go_to_spool_and_resend(tmp_path):
134
+ port = free_port()
135
+ cr = TraceReports(f"http://127.0.0.1:{port}", timeout=0.2, spool_dir=str(tmp_path), flush_timeout=0.5)
136
+ cr.run_id = 7
137
+ cr.log_info("antes de la caída", test_id=11)
138
+ cr.attach_screenshot(b"\x89PNG fake", "captura", test_id=11)
139
+ cr.end_run() # el servidor no responde: queda en el spool
140
+ assert cr.delivery["spooled"] == 3 # paso + captura + cierre de la ejecución
141
+ srv = FakeServer(port=port)
142
+ try:
143
+ s = Sender(srv.url, lambda extra: dict(extra or {}), spool_dir=str(tmp_path))
144
+ assert s.load_spool() == 3 and s.flush(10) == 0
145
+ assert os.listdir(tmp_path) == []
146
+ paths = [p for m, p, k, b in srv.requests]
147
+ assert paths[0].endswith("/logs") and paths[1].endswith("/screenshot") and paths[2] == "/api/v1/runs/7/finish"
148
+ finally:
149
+ srv.close()
150
+
151
+
152
+ def test_client_identity_context_and_expected_responses(monkeypatch):
153
+ monkeypatch.setenv("TRACEREPORTS_BRANCH", "feature/x")
154
+ monkeypatch.setenv("TRACEREPORTS_COMMIT", "abc123")
155
+ monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw3")
156
+ srv = FakeServer()
157
+ try:
158
+ cr = TraceReports(srv.url)
159
+ assert cr.start_run("Suite", environment="qa", project="shop") == 7
160
+ tid = cr.start_test("Login", key="tests/test_a.py::test_login", suite="tests/test_a.py")
161
+ cr.expect_response(401, url="*/api/auth*", test_id=tid)
162
+ res = cr.attach_network([
163
+ {"method": "POST", "url": "https://app/api/auth", "status": 401},
164
+ {"method": "GET", "url": "https://app/api/me", "status": 500},
165
+ ], test_id=tid)
166
+ cr.end_test("PASS", attempts=2, test_id=tid)
167
+ cr.end_run()
168
+ assert res == {"stored": 2, "errors": 1}
169
+ bodies = {p: json.loads(b) for m, p, k, b in srv.requests}
170
+ run = bodies["/api/v1/runs"]
171
+ assert run["project"] == "shop" and run["branch"] == "feature/x" and run["commit"] == "abc123"
172
+ test = bodies["/api/v1/runs/7/tests"]
173
+ assert test["key"] == "tests/test_a.py::test_login" and test["worker"] == "gw3"
174
+ conns = bodies["/api/v1/tests/11/network"]["connections"]
175
+ assert [c["expected"] for c in conns] == [True, False]
176
+ assert bodies["/api/v1/tests/11/finish"]["attempts"] == 2
177
+ assert bodies["/api/v1/runs/7/finish"] == {"interrupted": False}
178
+ assert [p for m, p, k, b in srv.requests][-1] == "/api/v1/runs/7/finish", "the run closes after the queue is flushed"
179
+ finally:
180
+ srv.close()
181
+
182
+
183
+ # ─── spool: durabilidad y límites ───
184
+
185
+ def _items_in(folder):
186
+ out = []
187
+ for name in os.listdir(folder):
188
+ with open(os.path.join(folder, name), encoding="utf-8") as fh:
189
+ out += [json.loads(line)["path"] for line in fh if line.strip()]
190
+ return out
191
+
192
+
193
+ def test_drain_while_sending_never_loses_another_event(tmp_path):
194
+ """A en vuelo + drenado + B encolado: B se envía y A queda a salvo (enviado y en el spool)."""
195
+ s = Sender("http://unused", lambda extra: dict(extra or {}), spool_dir=str(tmp_path))
196
+ started, release, posted = threading.Event(), threading.Event(), []
197
+
198
+ def fake_post(item):
199
+ posted.append(item.path)
200
+ if item.path == "/A":
201
+ started.set()
202
+ release.wait(5)
203
+ return "ok", {}
204
+
205
+ s._post = fake_post
206
+ s.enqueue("POST", "/A", b"a", "application/json", 1)
207
+ assert started.wait(5)
208
+ s.drain_to_spool() # se lleva A (en vuelo) al spool
209
+ s.enqueue("POST", "/B", b"b", "application/json", 1)
210
+ release.set()
211
+ assert s.flush(5) == 0
212
+ assert posted == ["/A", "/B"], "B must be sent, not silently removed"
213
+ assert _items_in(tmp_path) == ["/A"], "A was in flight when drained: it is kept in the spool"
214
+ assert s.stats["sent"] == 2 and s.stats["lost"] == 0
215
+ assert s._thread.is_alive(), "the worker must survive"
216
+
217
+
218
+ def test_drain_while_sending_without_spool_counts_it(tmp_path):
219
+ s = Sender("http://unused", lambda extra: dict(extra or {}))
220
+ started, release = threading.Event(), threading.Event()
221
+
222
+ def fake_post(item):
223
+ started.set()
224
+ release.wait(5)
225
+ return "retry", None # el envío en curso falla
226
+
227
+ s._post = fake_post
228
+ s.enqueue("POST", "/A", b"a", "application/json", 1)
229
+ assert started.wait(5)
230
+ assert s.drain_to_spool() == 1 and s.stats["lost"] == 1, "nothing disappears without being counted"
231
+ release.set()
232
+ assert s.flush(2) == 0 and s.pending == 0
233
+
234
+
235
+ ONE_SPOOL = "tracereports_spool_1700000000000_000000_1-a_0000.jsonl"
236
+
237
+
238
+ def _spool_one(folder, path="/api/v1/tests/1/logs"):
239
+ from tracereports.transport import _Item
240
+ with open(os.path.join(folder, ONE_SPOOL), "w", encoding="utf-8") as fh:
241
+ fh.write(_Item("POST", path, b"{}", "application/json", 1).to_json() + "\n")
242
+
243
+
244
+ def test_spool_file_survives_until_delivery(tmp_path, monkeypatch):
245
+ _spool_one(tmp_path)
246
+ s = Sender("http://unused", lambda extra: dict(extra or {}), spool_dir=str(tmp_path))
247
+ monkeypatch.setattr(s, "_ensure_thread", lambda: None) # nada se entrega
248
+ assert s.load_spool() == 1 and s.pending == 1
249
+ assert len(os.listdir(tmp_path)) == 1, "the durable copy must stay while the event is not delivered"
250
+
251
+ # el proceso "muere": otra instancia retoma el archivo reclamado cuando queda viejo
252
+ import tracereports.transport as tr
253
+ monkeypatch.setattr(tr, "SPOOL_STALE_SECONDS", 0.0)
254
+ srv = FakeServer()
255
+ try:
256
+ s2 = Sender(srv.url, lambda extra: dict(extra or {}), spool_dir=str(tmp_path))
257
+ time.sleep(0.05)
258
+ assert s2.load_spool() == 1 and s2.flush(5) == 0
259
+ assert os.listdir(tmp_path) == [], "the file is removed only after the event was delivered"
260
+ assert len(srv.requests) == 1
261
+ finally:
262
+ srv.close()
263
+
264
+
265
+ def test_spool_respects_queue_limits_and_concurrent_consumers(tmp_path):
266
+ s = Sender("http://unused", lambda extra: dict(extra or {}), spool_dir=str(tmp_path), max_items=0)
267
+ _spool_one(tmp_path)
268
+ assert s.load_spool() == 0 and s.pending == 0, "max_items must also apply to the spool"
269
+ assert os.listdir(tmp_path) == [ONE_SPOOL], "a file that does not fit is left as it was"
270
+
271
+ # un spool grande se escribe en tandas y dos consumidores no toman el mismo archivo
272
+ big = Sender("http://unused", lambda extra: dict(extra or {}), spool_dir=str(tmp_path / "big"))
273
+ for i in range(450):
274
+ big._q.append(__import__("tracereports.transport", fromlist=["_Item"])._Item("POST", f"/e{i}", b"x", "application/json", 1))
275
+ big.drain_to_spool()
276
+ assert len(os.listdir(tmp_path / "big")) == 3
277
+ a = Sender("http://unused", lambda extra: dict(extra or {}), spool_dir=str(tmp_path / "big"), max_items=250)
278
+ b = Sender("http://unused", lambda extra: dict(extra or {}), spool_dir=str(tmp_path / "big"), max_items=1000)
279
+ a._ensure_thread = b._ensure_thread = lambda: None
280
+ na, nb = a.load_spool(), b.load_spool()
281
+ assert na <= 250 and na + nb == 450, (na, nb)
282
+
283
+
284
+ # ─── spool: archivos grandes, orden y concurrencia ───
285
+
286
+ def _big_spool(folder, n, name="tracereports_spool_1700000000000_000000_4242-a_0000.jsonl"):
287
+ """Un solo archivo con n eventos (más de los que caben de una vez en la cola)."""
288
+ from tracereports.transport import _Item
289
+ os.makedirs(folder, exist_ok=True)
290
+ with open(os.path.join(folder, name), "w", encoding="utf-8") as fh:
291
+ for i in range(n):
292
+ fh.write(_Item("POST", f"/api/v1/tests/1/logs?e={i}", b"{}", "application/json", 1).to_json() + "\n")
293
+
294
+
295
+ def test_drains_in_the_same_millisecond_never_overwrite(tmp_path, monkeypatch):
296
+ import tracereports.transport as tr
297
+ monkeypatch.setattr(tr.time, "time", lambda: 1_700_000_000.123)
298
+ s = Sender("http://unused", lambda extra: dict(extra or {}), spool_dir=str(tmp_path))
299
+ s._ensure_thread = lambda: None
300
+ s.enqueue("POST", "/A", b"a", "application/json", 1)
301
+ s.drain_to_spool()
302
+ s.enqueue("POST", "/B", b"b", "application/json", 1)
303
+ s.drain_to_spool()
304
+ assert sorted(_items_in(tmp_path)) == ["/A", "/B"], "both drains must survive"
305
+ assert s.stats["spooled"] == 2 and s.stats["lost"] == 0
306
+ # y se reenvían en el orden en que se guardaron
307
+ r = Sender("http://unused", lambda extra: dict(extra or {}), spool_dir=str(tmp_path))
308
+ r._ensure_thread = lambda: None
309
+ r.load_spool()
310
+ assert [it.path for it in r._q] == ["/A", "/B"]
311
+
312
+
313
+ def test_old_file_just_claimed_is_not_taken_by_another_instance(tmp_path, monkeypatch):
314
+ _spool_one(tmp_path)
315
+ old = time.time() - 3600
316
+ f = os.path.join(tmp_path, os.listdir(tmp_path)[0])
317
+ os.utime(f, (old, old))
318
+ a = Sender("http://unused", lambda extra: dict(extra or {}), spool_dir=str(tmp_path))
319
+ b = Sender("http://unused", lambda extra: dict(extra or {}), spool_dir=str(tmp_path))
320
+ a._ensure_thread = b._ensure_thread = lambda: None
321
+ assert a.load_spool() == 1
322
+ assert b.load_spool() == 0 and b.spool_report["busy"] == 1, "a live claim is not stale"
323
+ # el dueño murió: nadie renueva el claim y, pasado el umbral, otra instancia lo retoma
324
+ claimed = os.path.join(tmp_path, os.listdir(tmp_path)[0])
325
+ os.utime(claimed, (old, old))
326
+ c = Sender("http://unused", lambda extra: dict(extra or {}), spool_dir=str(tmp_path))
327
+ c._ensure_thread = lambda: None
328
+ assert c.load_spool() == 1
329
+
330
+
331
+ def test_big_file_is_loaded_in_batches_and_keeps_the_rest(tmp_path):
332
+ _big_spool(tmp_path, 200)
333
+ s = Sender("http://unused", lambda extra: dict(extra or {}), spool_dir=str(tmp_path), max_items=100)
334
+ s._ensure_thread = lambda: None
335
+ assert s.load_spool() == 100 and s.pending == 100
336
+ assert sum(1 for _ in _items_in(tmp_path)) == 200, "loaded part (claimed) + durable rest"
337
+ order = [it.path for it in s._q]
338
+ assert order == [f"/api/v1/tests/1/logs?e={i}" for i in range(100)]
339
+
340
+
341
+ def test_resend_cli_sends_a_spool_file_bigger_than_the_queue(tmp_path, capsys):
342
+ from tracereports import resend
343
+ _big_spool(tmp_path, 5001)
344
+ srv = FakeServer()
345
+ try:
346
+ assert resend.main([str(tmp_path), "--url", srv.url, "--timeout", "120"]) == 0
347
+ sent = [p for m, p, k, b in srv.requests]
348
+ assert sent == [f"/api/v1/tests/1/logs?e={i}" for i in range(5001)], "all events, in order"
349
+ assert os.listdir(tmp_path) == []
350
+ finally:
351
+ srv.close()
352
+
353
+
354
+ def test_resend_cli_does_not_announce_success_when_evidence_remains(tmp_path, capsys):
355
+ from tracereports import resend
356
+ import tracereports.transport as tr
357
+ _big_spool(tmp_path, 3)
358
+ code = resend.main([str(tmp_path), "--url", "http://127.0.0.1:9", "--timeout", "1"])
359
+ out = capsys.readouterr().out
360
+ assert code == 1 and "No hay eventos pendientes" not in out and "Quedan" in out
361
+ assert len(_items_in(tmp_path)) == 3, "nothing lost"
362
+
363
+
364
+ def test_chunks_of_one_drain_are_resent_in_order(tmp_path):
365
+ s = Sender("http://unused", lambda extra: dict(extra or {}), spool_dir=str(tmp_path))
366
+ s._ensure_thread = lambda: None
367
+ for i in range(450):
368
+ s.enqueue("POST", f"/e{i}", b"x", "application/json", 1)
369
+ s.drain_to_spool()
370
+ assert len(os.listdir(tmp_path)) == 3
371
+ r = Sender("http://unused", lambda extra: dict(extra or {}), spool_dir=str(tmp_path))
372
+ r._ensure_thread = lambda: None
373
+ r.load_spool()
374
+ assert [it.path for it in r._q] == [f"/e{i}" for i in range(450)]
375
+
376
+
377
+ # ─── Carga del spool con productores concurrentes ───
378
+
379
+ def _blocking(monkeypatch, target, attr):
380
+ """Bloquea target.attr la primera vez que se llama hasta que el test lo libera (sin sleeps)."""
381
+ entered, release = threading.Event(), threading.Event()
382
+ original = getattr(target, attr)
383
+ calls = []
384
+
385
+ def wrapper(*a, **kw):
386
+ if not calls:
387
+ calls.append(1)
388
+ entered.set()
389
+ assert release.wait(5)
390
+ return original(*a, **kw)
391
+
392
+ monkeypatch.setattr(target, attr, wrapper)
393
+ return entered, release
394
+
395
+
396
+ def test_load_spool_never_exceeds_limits_with_concurrent_enqueue(tmp_path, monkeypatch):
397
+ """Reproducción del informe: un evento nuevo entra mientras load_spool lee el archivo."""
398
+ from tracereports.transport import _Item
399
+ _spool_one(tmp_path, path="/old")
400
+ s = Sender("http://unused", lambda extra: dict(extra or {}), spool_dir=str(tmp_path), max_items=1, max_bytes=2)
401
+ s._ensure_thread = lambda: None
402
+ entered, release = _blocking(monkeypatch, _Item, "from_json")
403
+ t = threading.Thread(target=s.load_spool)
404
+ t.start()
405
+ assert entered.wait(5) # ya reclamó y está leyendo el archivo
406
+ assert s.enqueue("POST", "/new", b"{}", "application/json", 1)
407
+ release.set()
408
+ t.join(5)
409
+ assert s.pending <= 1 and s._bytes <= 2, (s.pending, s._bytes)
410
+ assert [it.path for it in s._q] == ["/new"]
411
+ assert _items_in(tmp_path) == ["/old"], "what did not fit stays on disk for the next batch"
412
+
413
+
414
+ def test_reserved_capacity_is_not_taken_while_a_file_is_split(tmp_path, monkeypatch):
415
+ """La reserva vale mientras se divide el archivo: ni enqueue ni otra carga la usan."""
416
+ _big_spool(tmp_path, 3)
417
+ s = Sender("http://unused", lambda extra: dict(extra or {}), spool_dir=str(tmp_path), max_items=2)
418
+ s._ensure_thread = lambda: None
419
+ entered, release = _blocking(monkeypatch, s, "_write_file")
420
+ t = threading.Thread(target=s.load_spool)
421
+ t.start()
422
+ assert entered.wait(5) # reservó 2 lugares y está escribiendo el resto
423
+ assert not s.enqueue("POST", "/new", b"x", "application/json", 1), "reserved slots are not free"
424
+ assert s.load_spool() == 0, "a second load on the same Sender respects the reservation"
425
+ release.set()
426
+ t.join(5)
427
+ assert s.pending == 2 and [it.path for it in s._q] == ["/api/v1/tests/1/logs?e=0", "/api/v1/tests/1/logs?e=1"]
428
+ assert sorted(_items_in(tmp_path)) == sorted(f"/api/v1/tests/1/logs?e={i}" for i in range(3)), \
429
+ "loaded part (claimed) + the rest, all durable"
430
+ assert s._reserved_items == 0 and s._reserved_bytes == 0
@@ -0,0 +1,31 @@
1
+ """
2
+ TraceReports — cliente Python.
3
+
4
+ from tracereports import TraceReports # API REST (steps, capturas, red)
5
+ from tracereports import attach_listeners, reportar_red # captura de red con Playwright
6
+
7
+ Con pytest no hace falta código: `pytest --tracereports` (ver tracereports.pytest_plugin).
8
+ """
9
+
10
+ from .client import TraceReports, __version__
11
+ from .dom import capturar_dom
12
+ from .network import (
13
+ attach_listeners,
14
+ conexiones_del_test,
15
+ esperar_conexion,
16
+ guardar_red_local,
17
+ reportar_red,
18
+ resumir_red,
19
+ )
20
+
21
+ __all__ = [
22
+ "TraceReports",
23
+ "attach_listeners",
24
+ "capturar_dom",
25
+ "conexiones_del_test",
26
+ "esperar_conexion",
27
+ "guardar_red_local",
28
+ "reportar_red",
29
+ "resumir_red",
30
+ "__version__",
31
+ ]