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.
- tracereports-0.2.0/PKG-INFO +33 -0
- tracereports-0.2.0/README.md +12 -0
- tracereports-0.2.0/pyproject.toml +35 -0
- tracereports-0.2.0/setup.cfg +4 -0
- tracereports-0.2.0/tests/test_env_file.py +107 -0
- tracereports-0.2.0/tests/test_transport.py +430 -0
- tracereports-0.2.0/tracereports/__init__.py +31 -0
- tracereports-0.2.0/tracereports/_env.py +90 -0
- tracereports-0.2.0/tracereports/client.py +582 -0
- tracereports-0.2.0/tracereports/context.py +43 -0
- tracereports-0.2.0/tracereports/dom.py +50 -0
- tracereports-0.2.0/tracereports/network.py +291 -0
- tracereports-0.2.0/tracereports/pytest_plugin.py +290 -0
- tracereports-0.2.0/tracereports/resend.py +63 -0
- tracereports-0.2.0/tracereports/transport.py +478 -0
- tracereports-0.2.0/tracereports.egg-info/PKG-INFO +33 -0
- tracereports-0.2.0/tracereports.egg-info/SOURCES.txt +19 -0
- tracereports-0.2.0/tracereports.egg-info/dependency_links.txt +1 -0
- tracereports-0.2.0/tracereports.egg-info/entry_points.txt +2 -0
- tracereports-0.2.0/tracereports.egg-info/requires.txt +6 -0
- tracereports-0.2.0/tracereports.egg-info/top_level.txt +1 -0
|
@@ -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,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
|
+
]
|