wup 0.2.15__tar.gz → 0.2.16__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.
- {wup-0.2.15/wup.egg-info → wup-0.2.16}/PKG-INFO +49 -7
- {wup-0.2.15 → wup-0.2.16}/README.md +48 -6
- {wup-0.2.15 → wup-0.2.16}/pyproject.toml +1 -1
- wup-0.2.16/tests/test_web_client.py +167 -0
- {wup-0.2.15 → wup-0.2.16}/wup/__init__.py +1 -1
- {wup-0.2.15 → wup-0.2.16}/wup/config.py +19 -0
- {wup-0.2.15 → wup-0.2.16}/wup/models/config.py +11 -0
- {wup-0.2.15 → wup-0.2.16}/wup/testql_watcher.py +16 -0
- wup-0.2.16/wup/web_client.py +178 -0
- wup-0.2.16/wup-web/tests/__init__.py +0 -0
- wup-0.2.16/wup-web/tests/conftest.py +23 -0
- wup-0.2.16/wup-web/tests/test_dashboard.py +35 -0
- wup-0.2.16/wup-web/tests/test_drivers.py +50 -0
- wup-0.2.16/wup-web/tests/test_events.py +95 -0
- wup-0.2.16/wup-web/wup_web/__init__.py +7 -0
- wup-0.2.16/wup-web/wup_web/__main__.py +21 -0
- wup-0.2.16/wup-web/wup_web/main.py +44 -0
- wup-0.2.16/wup-web/wup_web/models.py +59 -0
- wup-0.2.16/wup-web/wup_web/routers/__init__.py +1 -0
- wup-0.2.16/wup-web/wup_web/routers/dashboard.py +24 -0
- wup-0.2.16/wup-web/wup_web/routers/drivers.py +129 -0
- wup-0.2.16/wup-web/wup_web/routers/events.py +48 -0
- wup-0.2.16/wup-web/wup_web/storage.py +110 -0
- {wup-0.2.15 → wup-0.2.16/wup.egg-info}/PKG-INFO +49 -7
- wup-0.2.16/wup.egg-info/SOURCES.txt +38 -0
- wup-0.2.16/wup.egg-info/top_level.txt +2 -0
- wup-0.2.15/wup.egg-info/SOURCES.txt +0 -22
- wup-0.2.15/wup.egg-info/top_level.txt +0 -1
- {wup-0.2.15 → wup-0.2.16}/LICENSE +0 -0
- {wup-0.2.15 → wup-0.2.16}/setup.cfg +0 -0
- {wup-0.2.15 → wup-0.2.16}/tests/test_e2e.py +0 -0
- {wup-0.2.15 → wup-0.2.16}/tests/test_testql_watcher.py +0 -0
- {wup-0.2.15 → wup-0.2.16}/tests/test_wup.py +0 -0
- {wup-0.2.15 → wup-0.2.16}/wup/cli.py +0 -0
- {wup-0.2.15 → wup-0.2.16}/wup/core.py +0 -0
- {wup-0.2.15 → wup-0.2.16}/wup/dependency_mapper.py +0 -0
- {wup-0.2.15 → wup-0.2.16}/wup/models/__init__.py +0 -0
- {wup-0.2.15 → wup-0.2.16}/wup/testql_discovery.py +0 -0
- {wup-0.2.15 → wup-0.2.16}/wup/visual_diff.py +0 -0
- {wup-0.2.15 → wup-0.2.16}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.15 → wup-0.2.16}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.15 → wup-0.2.16}/wup.egg-info/requires.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wup
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.16
|
|
4
4
|
Summary: WUP (What's Up) - Intelligent file watcher for regression testing in large projects
|
|
5
5
|
Author-email: Tom Sapletta <tom@sapletta.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -29,17 +29,17 @@ Dynamic: license-file
|
|
|
29
29
|
|
|
30
30
|
## AI Cost Tracking
|
|
31
31
|
|
|
32
|
-
    
|
|
33
|
+
  
|
|
34
34
|
|
|
35
|
-
- 🤖 **LLM usage:** $2.
|
|
36
|
-
- 👤 **Human dev:** ~$
|
|
35
|
+
- 🤖 **LLM usage:** $2.5500 (17 commits)
|
|
36
|
+
- 👤 **Human dev:** ~$445 (4.5h @ $100/h, 30min dedup)
|
|
37
37
|
|
|
38
38
|
Generated on 2026-04-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
-
    
|
|
43
43
|
|
|
44
44
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
45
45
|
|
|
@@ -333,6 +333,39 @@ Visible in `wup status` as a "Visual DOM diffs" section.
|
|
|
333
333
|
|
|
334
334
|
If Playwright is not installed, the visual diff module logs a warning and skips scanning — it does **not** break the watcher.
|
|
335
335
|
|
|
336
|
+
## Web Dashboard (wup-web)
|
|
337
|
+
|
|
338
|
+
Optional FastAPI backend that receives events from WUP agents and renders a live dashboard.
|
|
339
|
+
|
|
340
|
+
### Run
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
pip install -e wup-web/
|
|
344
|
+
wup-web --reload --port 8000
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Open <http://localhost:8000/> to see regressions, passes, anomalies, visual diffs, and health transitions in real time.
|
|
348
|
+
|
|
349
|
+
### Configure agent → backend
|
|
350
|
+
|
|
351
|
+
```yaml
|
|
352
|
+
# wup.yaml
|
|
353
|
+
web:
|
|
354
|
+
enabled: true
|
|
355
|
+
endpoint: "http://localhost:8000"
|
|
356
|
+
timeout_s: 2.0
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Or via env:
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
export WUP_WEB_ENDPOINT=http://localhost:8000
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
The agent fire-and-forgets `REGRESSION`, `PASS`, `ANOMALY`, `VISUAL_DIFF`, and `HEALTH_TRANSITION` events. Network errors never break the watcher (soft-fail).
|
|
366
|
+
|
|
367
|
+
See `wup-web/README.md` for full API reference and driver endpoints (DOM diff, browserless, anomaly).
|
|
368
|
+
|
|
336
369
|
## Project Structure
|
|
337
370
|
|
|
338
371
|
```
|
|
@@ -346,12 +379,21 @@ wup/
|
|
|
346
379
|
│ ├── testql_discovery.py # TestQLEndpointDiscovery: scenario parsing
|
|
347
380
|
│ ├── testql_watcher.py # TestQLWatcher: scenario runner + health tracking
|
|
348
381
|
│ ├── visual_diff.py # VisualDiffer: Playwright DOM snapshot + diff engine
|
|
382
|
+
│ ├── web_client.py # WebClient: async HTTP event sink → wup-web
|
|
349
383
|
│ └── models/
|
|
350
384
|
│ ├── __init__.py
|
|
351
|
-
│ └── config.py # Dataclasses: WupConfig, VisualDiffConfig,
|
|
385
|
+
│ └── config.py # Dataclasses: WupConfig, VisualDiffConfig, WebConfig...
|
|
386
|
+
├── wup-web/ # Optional FastAPI dashboard (separate package)
|
|
387
|
+
│ ├── wup_web/
|
|
388
|
+
│ │ ├── main.py # FastAPI app
|
|
389
|
+
│ │ ├── routers/ # events, drivers, dashboard
|
|
390
|
+
│ │ ├── storage.py # EventStore (in-memory + JSONL)
|
|
391
|
+
│ │ └── templates/ # index.html dashboard
|
|
392
|
+
│ └── tests/ # FastAPI endpoint tests (pytest + TestClient)
|
|
352
393
|
├── tests/
|
|
353
394
|
│ ├── test_wup.py # unit/integration tests (incl. VisualDiffer, config)
|
|
354
395
|
│ ├── test_testql_watcher.py # TestQLWatcher + VisualDiffer integration tests
|
|
396
|
+
│ ├── test_web_client.py # WebClient + WebConfig tests
|
|
355
397
|
│ └── test_e2e.py # end-to-end CLI tests
|
|
356
398
|
├── examples/
|
|
357
399
|
│ ├── fastapi-app/ # FastAPI example project
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
    
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $2.
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $2.5500 (17 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$445 (4.5h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
12
|
Generated on 2026-04-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
    
|
|
17
17
|
|
|
18
18
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
19
19
|
|
|
@@ -307,6 +307,39 @@ Visible in `wup status` as a "Visual DOM diffs" section.
|
|
|
307
307
|
|
|
308
308
|
If Playwright is not installed, the visual diff module logs a warning and skips scanning — it does **not** break the watcher.
|
|
309
309
|
|
|
310
|
+
## Web Dashboard (wup-web)
|
|
311
|
+
|
|
312
|
+
Optional FastAPI backend that receives events from WUP agents and renders a live dashboard.
|
|
313
|
+
|
|
314
|
+
### Run
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
pip install -e wup-web/
|
|
318
|
+
wup-web --reload --port 8000
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Open <http://localhost:8000/> to see regressions, passes, anomalies, visual diffs, and health transitions in real time.
|
|
322
|
+
|
|
323
|
+
### Configure agent → backend
|
|
324
|
+
|
|
325
|
+
```yaml
|
|
326
|
+
# wup.yaml
|
|
327
|
+
web:
|
|
328
|
+
enabled: true
|
|
329
|
+
endpoint: "http://localhost:8000"
|
|
330
|
+
timeout_s: 2.0
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Or via env:
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
export WUP_WEB_ENDPOINT=http://localhost:8000
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
The agent fire-and-forgets `REGRESSION`, `PASS`, `ANOMALY`, `VISUAL_DIFF`, and `HEALTH_TRANSITION` events. Network errors never break the watcher (soft-fail).
|
|
340
|
+
|
|
341
|
+
See `wup-web/README.md` for full API reference and driver endpoints (DOM diff, browserless, anomaly).
|
|
342
|
+
|
|
310
343
|
## Project Structure
|
|
311
344
|
|
|
312
345
|
```
|
|
@@ -320,12 +353,21 @@ wup/
|
|
|
320
353
|
│ ├── testql_discovery.py # TestQLEndpointDiscovery: scenario parsing
|
|
321
354
|
│ ├── testql_watcher.py # TestQLWatcher: scenario runner + health tracking
|
|
322
355
|
│ ├── visual_diff.py # VisualDiffer: Playwright DOM snapshot + diff engine
|
|
356
|
+
│ ├── web_client.py # WebClient: async HTTP event sink → wup-web
|
|
323
357
|
│ └── models/
|
|
324
358
|
│ ├── __init__.py
|
|
325
|
-
│ └── config.py # Dataclasses: WupConfig, VisualDiffConfig,
|
|
359
|
+
│ └── config.py # Dataclasses: WupConfig, VisualDiffConfig, WebConfig...
|
|
360
|
+
├── wup-web/ # Optional FastAPI dashboard (separate package)
|
|
361
|
+
│ ├── wup_web/
|
|
362
|
+
│ │ ├── main.py # FastAPI app
|
|
363
|
+
│ │ ├── routers/ # events, drivers, dashboard
|
|
364
|
+
│ │ ├── storage.py # EventStore (in-memory + JSONL)
|
|
365
|
+
│ │ └── templates/ # index.html dashboard
|
|
366
|
+
│ └── tests/ # FastAPI endpoint tests (pytest + TestClient)
|
|
326
367
|
├── tests/
|
|
327
368
|
│ ├── test_wup.py # unit/integration tests (incl. VisualDiffer, config)
|
|
328
369
|
│ ├── test_testql_watcher.py # TestQLWatcher + VisualDiffer integration tests
|
|
370
|
+
│ ├── test_web_client.py # WebClient + WebConfig tests
|
|
329
371
|
│ └── test_e2e.py # end-to-end CLI tests
|
|
330
372
|
├── examples/
|
|
331
373
|
│ ├── fastapi-app/ # FastAPI example project
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Tests for wup.web_client and WebConfig."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
9
|
+
from typing import List, Tuple
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from wup.models.config import WebConfig
|
|
14
|
+
from wup.web_client import WebClient, resolve_endpoint
|
|
15
|
+
|
|
16
|
+
# Skip all tests in this module if httpx is not installed
|
|
17
|
+
pytest.importorskip("httpx")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Lightweight HTTP recorder server (no external dep)
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
class _Recorder:
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self.requests: List[Tuple[str, str, dict]] = [] # (method, path, body)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _make_handler(recorder: _Recorder, status: int = 201):
|
|
30
|
+
class Handler(BaseHTTPRequestHandler):
|
|
31
|
+
def log_message(self, *_): pass
|
|
32
|
+
|
|
33
|
+
def do_POST(self):
|
|
34
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
35
|
+
raw = self.rfile.read(length).decode("utf-8") if length else "{}"
|
|
36
|
+
try:
|
|
37
|
+
body = json.loads(raw)
|
|
38
|
+
except json.JSONDecodeError:
|
|
39
|
+
body = {"_raw": raw}
|
|
40
|
+
recorder.requests.append((self.command, self.path, body))
|
|
41
|
+
self.send_response(status)
|
|
42
|
+
self.send_header("Content-Type", "application/json")
|
|
43
|
+
self.end_headers()
|
|
44
|
+
self.wfile.write(b'{"accepted": true}')
|
|
45
|
+
|
|
46
|
+
return Handler
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.fixture
|
|
50
|
+
def recorder_server():
|
|
51
|
+
rec = _Recorder()
|
|
52
|
+
server = HTTPServer(("127.0.0.1", 0), _make_handler(rec))
|
|
53
|
+
port = server.server_address[1]
|
|
54
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
55
|
+
thread.start()
|
|
56
|
+
try:
|
|
57
|
+
yield f"http://127.0.0.1:{port}", rec
|
|
58
|
+
finally:
|
|
59
|
+
server.shutdown()
|
|
60
|
+
server.server_close()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Tests
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def test_resolve_endpoint_from_config():
|
|
68
|
+
cfg = WebConfig(endpoint="http://localhost:8000/")
|
|
69
|
+
assert resolve_endpoint(cfg) == "http://localhost:8000"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_resolve_endpoint_from_env(monkeypatch):
|
|
73
|
+
cfg = WebConfig(endpoint="", endpoint_env="MY_WUP_WEB")
|
|
74
|
+
monkeypatch.setenv("MY_WUP_WEB", "http://my-host:9000/")
|
|
75
|
+
assert resolve_endpoint(cfg) == "http://my-host:9000"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_resolve_endpoint_empty(monkeypatch):
|
|
79
|
+
cfg = WebConfig(endpoint="", endpoint_env="WUP_WEB_NONE")
|
|
80
|
+
monkeypatch.delenv("WUP_WEB_NONE", raising=False)
|
|
81
|
+
assert resolve_endpoint(cfg) == ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_is_active_false_when_disabled():
|
|
85
|
+
cfg = WebConfig(enabled=False, endpoint="http://x")
|
|
86
|
+
assert WebClient(cfg).is_active is False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_is_active_false_when_no_endpoint():
|
|
90
|
+
cfg = WebConfig(enabled=True, endpoint="", endpoint_env="WUP_WEB_NONE")
|
|
91
|
+
assert WebClient(cfg).is_active is False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_send_event_disabled_returns_false():
|
|
95
|
+
client = WebClient(WebConfig(enabled=False))
|
|
96
|
+
assert asyncio.run(client.send_event({"type": "REGRESSION"})) is False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_send_event_posts_to_recorder(recorder_server):
|
|
100
|
+
base, rec = recorder_server
|
|
101
|
+
cfg = WebConfig(enabled=True, endpoint=base, timeout_s=5.0)
|
|
102
|
+
client = WebClient(cfg)
|
|
103
|
+
assert client.is_active is True
|
|
104
|
+
|
|
105
|
+
ok = asyncio.run(client.send_event({
|
|
106
|
+
"type": "REGRESSION",
|
|
107
|
+
"service": "users-web",
|
|
108
|
+
"file": "app/users/routes.py",
|
|
109
|
+
"endpoint": "/api/users",
|
|
110
|
+
"status": "fail",
|
|
111
|
+
"reason": "TestQL exit code 1",
|
|
112
|
+
}))
|
|
113
|
+
assert ok is True
|
|
114
|
+
assert len(rec.requests) == 1
|
|
115
|
+
method, path, body = rec.requests[0]
|
|
116
|
+
assert method == "POST"
|
|
117
|
+
assert path == "/events"
|
|
118
|
+
assert body["type"] == "REGRESSION"
|
|
119
|
+
assert body["service"] == "users-web"
|
|
120
|
+
assert "timestamp" in body # auto-injected
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_send_event_with_api_key(recorder_server):
|
|
124
|
+
base, rec = recorder_server
|
|
125
|
+
cfg = WebConfig(enabled=True, endpoint=base, api_key="secret-token")
|
|
126
|
+
client = WebClient(cfg)
|
|
127
|
+
asyncio.run(client.send_event({"type": "PASS", "service": "x"}))
|
|
128
|
+
# body received OK; auth header is set on the client side (verified by no failure)
|
|
129
|
+
assert len(rec.requests) == 1
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_send_event_swallows_connection_error():
|
|
133
|
+
"""Unreachable host must NOT raise — soft-fail by design."""
|
|
134
|
+
cfg = WebConfig(
|
|
135
|
+
enabled=True,
|
|
136
|
+
endpoint="http://127.0.0.1:1", # almost certainly closed
|
|
137
|
+
timeout_s=0.5,
|
|
138
|
+
)
|
|
139
|
+
client = WebClient(cfg)
|
|
140
|
+
ok = asyncio.run(client.send_event({"type": "REGRESSION"}))
|
|
141
|
+
assert ok is False # no exception raised
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_send_regression_helper(recorder_server):
|
|
145
|
+
base, rec = recorder_server
|
|
146
|
+
client = WebClient(WebConfig(enabled=True, endpoint=base))
|
|
147
|
+
ok = asyncio.run(client.send_regression(
|
|
148
|
+
service="users", file="x.py", endpoint="/api/users", reason="500",
|
|
149
|
+
))
|
|
150
|
+
assert ok is True
|
|
151
|
+
body = rec.requests[0][2]
|
|
152
|
+
assert body["type"] == "REGRESSION"
|
|
153
|
+
assert body["service"] == "users"
|
|
154
|
+
assert body["status"] == "fail"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_send_health_transition_helper(recorder_server):
|
|
158
|
+
base, rec = recorder_server
|
|
159
|
+
client = WebClient(WebConfig(enabled=True, endpoint=base))
|
|
160
|
+
ok = asyncio.run(client.send_health_transition(
|
|
161
|
+
service="users", from_status="up", to_status="down",
|
|
162
|
+
))
|
|
163
|
+
assert ok is True
|
|
164
|
+
body = rec.requests[0][2]
|
|
165
|
+
assert body["type"] == "HEALTH_TRANSITION"
|
|
166
|
+
assert body["from"] == "up"
|
|
167
|
+
assert body["to"] == "down"
|
|
@@ -7,7 +7,7 @@ WUP monitors file changes and runs intelligent regression tests using a 3-layer
|
|
|
7
7
|
3. Detail Layer: Full tests with blame reports (only on failure)
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
__version__ = "0.2.
|
|
10
|
+
__version__ = "0.2.16"
|
|
11
11
|
__author__ = "Tom Sapletta"
|
|
12
12
|
|
|
13
13
|
from .config import load_config, save_config, get_default_config
|
|
@@ -20,6 +20,7 @@ from .models.config import (
|
|
|
20
20
|
NotifyConfig,
|
|
21
21
|
ServiceTestConfig,
|
|
22
22
|
VisualDiffConfig,
|
|
23
|
+
WebConfig,
|
|
23
24
|
)
|
|
24
25
|
|
|
25
26
|
|
|
@@ -218,6 +219,16 @@ def validate_config(raw: dict) -> WupConfig:
|
|
|
218
219
|
headless=vd_raw.get("headless", True),
|
|
219
220
|
)
|
|
220
221
|
|
|
222
|
+
# Parse web config (event sink)
|
|
223
|
+
web_raw = raw.get("web", {})
|
|
224
|
+
web = WebConfig(
|
|
225
|
+
enabled=web_raw.get("enabled", False),
|
|
226
|
+
endpoint=web_raw.get("endpoint", ""),
|
|
227
|
+
endpoint_env=web_raw.get("endpoint_env", "WUP_WEB_ENDPOINT"),
|
|
228
|
+
timeout_s=float(web_raw.get("timeout_s", 2.0)),
|
|
229
|
+
api_key=web_raw.get("api_key", ""),
|
|
230
|
+
)
|
|
231
|
+
|
|
221
232
|
return WupConfig(
|
|
222
233
|
project=project,
|
|
223
234
|
watch=watch,
|
|
@@ -225,6 +236,7 @@ def validate_config(raw: dict) -> WupConfig:
|
|
|
225
236
|
test_strategy=test_strategy,
|
|
226
237
|
testql=testql,
|
|
227
238
|
visual_diff=visual_diff,
|
|
239
|
+
web=web,
|
|
228
240
|
)
|
|
229
241
|
|
|
230
242
|
|
|
@@ -302,6 +314,13 @@ def save_config(config: WupConfig, output_path: Path):
|
|
|
302
314
|
"threshold_removed": config.visual_diff.threshold_removed,
|
|
303
315
|
"threshold_changed": config.visual_diff.threshold_changed,
|
|
304
316
|
"headless": config.visual_diff.headless,
|
|
317
|
+
},
|
|
318
|
+
"web": {
|
|
319
|
+
"enabled": config.web.enabled,
|
|
320
|
+
"endpoint": config.web.endpoint,
|
|
321
|
+
"endpoint_env": config.web.endpoint_env,
|
|
322
|
+
"timeout_s": config.web.timeout_s,
|
|
323
|
+
"api_key": config.web.api_key,
|
|
305
324
|
}
|
|
306
325
|
}
|
|
307
326
|
|
|
@@ -83,6 +83,16 @@ class VisualDiffConfig:
|
|
|
83
83
|
headless: bool = True
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
@dataclass
|
|
87
|
+
class WebConfig:
|
|
88
|
+
"""Configuration for sending events to wup-web backend."""
|
|
89
|
+
enabled: bool = False
|
|
90
|
+
endpoint: str = "" # e.g. "http://localhost:8000/events"
|
|
91
|
+
endpoint_env: str = "WUP_WEB_ENDPOINT"
|
|
92
|
+
timeout_s: float = 2.0 # short — must not block watcher
|
|
93
|
+
api_key: str = "" # optional bearer token
|
|
94
|
+
|
|
95
|
+
|
|
86
96
|
@dataclass
|
|
87
97
|
class ProjectConfig:
|
|
88
98
|
"""Project metadata."""
|
|
@@ -99,3 +109,4 @@ class WupConfig:
|
|
|
99
109
|
test_strategy: TestStrategyConfig = field(default_factory=TestStrategyConfig)
|
|
100
110
|
testql: TestQLConfig = field(default_factory=TestQLConfig)
|
|
101
111
|
visual_diff: VisualDiffConfig = field(default_factory=VisualDiffConfig)
|
|
112
|
+
web: WebConfig = field(default_factory=WebConfig)
|
|
@@ -16,6 +16,7 @@ from .config import load_config
|
|
|
16
16
|
from .core import WupWatcher
|
|
17
17
|
from .models.config import WupConfig, ServiceConfig
|
|
18
18
|
from .visual_diff import VisualDiffer
|
|
19
|
+
from .web_client import WebClient
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
class BrowserNotifier:
|
|
@@ -96,6 +97,7 @@ class TestQLWatcher(WupWatcher):
|
|
|
96
97
|
self.service_health = self._load_service_health()
|
|
97
98
|
self.config = config
|
|
98
99
|
self.visual_differ = VisualDiffer(project_root, config.visual_diff) if config and config.visual_diff else None
|
|
100
|
+
self.web_client = WebClient(config.web) if config and getattr(config, "web", None) else WebClient()
|
|
99
101
|
|
|
100
102
|
def _load_service_health(self) -> Dict[str, Dict]:
|
|
101
103
|
if not self.health_state_path.exists():
|
|
@@ -164,6 +166,20 @@ class TestQLWatcher(WupWatcher):
|
|
|
164
166
|
}
|
|
165
167
|
)
|
|
166
168
|
|
|
169
|
+
# Fire-and-forget: forward event to wup-web backend if active
|
|
170
|
+
if self.web_client.is_active:
|
|
171
|
+
try:
|
|
172
|
+
asyncio.ensure_future(
|
|
173
|
+
self.web_client.send_health_transition(
|
|
174
|
+
service=service,
|
|
175
|
+
from_status=previous_status,
|
|
176
|
+
to_status=status,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
except RuntimeError:
|
|
180
|
+
# No running event loop — skip silently
|
|
181
|
+
pass
|
|
182
|
+
|
|
167
183
|
def _tokenize_service(self, service: str) -> List[str]:
|
|
168
184
|
raw_tokens = re.split(r"[^a-zA-Z0-9]+", service.lower())
|
|
169
185
|
return [token for token in raw_tokens if len(token) >= 3]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WUP Web Client.
|
|
3
|
+
|
|
4
|
+
Lightweight async HTTP client used by WUP agents to push events
|
|
5
|
+
(REGRESSION, ANOMALY, PASS, VISUAL_DIFF, HEALTH_TRANSITION) to the
|
|
6
|
+
optional `wup-web` FastAPI backend.
|
|
7
|
+
|
|
8
|
+
Design constraints:
|
|
9
|
+
- **Soft-fail**: any network/HTTP error must NOT break the watcher.
|
|
10
|
+
- **Optional dependency**: httpx is optional; without it the client
|
|
11
|
+
becomes a no-op and logs a single warning.
|
|
12
|
+
- **Short timeout**: configurable via WebConfig.timeout_s (default 2s).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import asdict, is_dataclass
|
|
20
|
+
from typing import Any, Dict, Optional
|
|
21
|
+
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
|
|
24
|
+
from .models.config import WebConfig
|
|
25
|
+
|
|
26
|
+
_console = Console()
|
|
27
|
+
_HTTPX_AVAILABLE: Optional[bool] = None
|
|
28
|
+
_HTTPX_WARN_LOGGED: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _httpx_available() -> bool:
|
|
32
|
+
global _HTTPX_AVAILABLE, _HTTPX_WARN_LOGGED
|
|
33
|
+
if _HTTPX_AVAILABLE is None:
|
|
34
|
+
try:
|
|
35
|
+
import httpx # noqa: F401
|
|
36
|
+
_HTTPX_AVAILABLE = True
|
|
37
|
+
except ImportError:
|
|
38
|
+
_HTTPX_AVAILABLE = False
|
|
39
|
+
if not _HTTPX_WARN_LOGGED:
|
|
40
|
+
_console.print(
|
|
41
|
+
"[yellow]wup.web_client: httpx not installed — events will not be sent[/yellow]"
|
|
42
|
+
)
|
|
43
|
+
_HTTPX_WARN_LOGGED = True
|
|
44
|
+
return _HTTPX_AVAILABLE
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_endpoint(cfg: WebConfig) -> str:
|
|
48
|
+
"""Return endpoint URL from cfg or env, with trailing slash stripped."""
|
|
49
|
+
if cfg.endpoint:
|
|
50
|
+
return cfg.endpoint.rstrip("/")
|
|
51
|
+
env_var = cfg.endpoint_env or "WUP_WEB_ENDPOINT"
|
|
52
|
+
return os.environ.get(env_var, "").rstrip("/")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _normalize(payload: Any) -> Any:
|
|
56
|
+
"""Convert dataclasses to plain dicts for JSON serialization."""
|
|
57
|
+
if is_dataclass(payload):
|
|
58
|
+
return asdict(payload)
|
|
59
|
+
if isinstance(payload, dict):
|
|
60
|
+
return {k: _normalize(v) for k, v in payload.items()}
|
|
61
|
+
if isinstance(payload, (list, tuple)):
|
|
62
|
+
return [_normalize(v) for v in payload]
|
|
63
|
+
return payload
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class WebClient:
|
|
67
|
+
"""
|
|
68
|
+
Async event sink for the wup-web backend.
|
|
69
|
+
|
|
70
|
+
Usage::
|
|
71
|
+
|
|
72
|
+
client = WebClient(config.web)
|
|
73
|
+
await client.send_event({
|
|
74
|
+
"type": "REGRESSION",
|
|
75
|
+
"service": "users-web",
|
|
76
|
+
"file": "app/users/routes.py",
|
|
77
|
+
"endpoint": "/api/users",
|
|
78
|
+
"status": "fail",
|
|
79
|
+
"reason": "TestQL exit code 1",
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
All public methods are coroutines and never raise — they swallow
|
|
83
|
+
network errors and log them.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, cfg: Optional[WebConfig] = None) -> None:
|
|
87
|
+
self.cfg = cfg or WebConfig()
|
|
88
|
+
self.endpoint = resolve_endpoint(self.cfg)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def is_active(self) -> bool:
|
|
92
|
+
return bool(self.cfg.enabled and self.endpoint and _httpx_available())
|
|
93
|
+
|
|
94
|
+
def _headers(self) -> Dict[str, str]:
|
|
95
|
+
h = {"Content-Type": "application/json", "User-Agent": "wup-web-client"}
|
|
96
|
+
if self.cfg.api_key:
|
|
97
|
+
h["Authorization"] = f"Bearer {self.cfg.api_key}"
|
|
98
|
+
return h
|
|
99
|
+
|
|
100
|
+
async def send_event(self, event: Dict[str, Any]) -> bool:
|
|
101
|
+
"""
|
|
102
|
+
POST a single event to `<endpoint>/events`.
|
|
103
|
+
|
|
104
|
+
Returns True on 2xx, False otherwise (including disabled / no httpx).
|
|
105
|
+
Never raises.
|
|
106
|
+
"""
|
|
107
|
+
if not self.is_active:
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
payload = _normalize(event)
|
|
111
|
+
payload.setdefault("timestamp", int(time.time()))
|
|
112
|
+
|
|
113
|
+
url = f"{self.endpoint}/events"
|
|
114
|
+
try:
|
|
115
|
+
import httpx
|
|
116
|
+
async with httpx.AsyncClient(timeout=self.cfg.timeout_s) as client:
|
|
117
|
+
resp = await client.post(url, json=payload, headers=self._headers())
|
|
118
|
+
if 200 <= resp.status_code < 300:
|
|
119
|
+
return True
|
|
120
|
+
_console.print(
|
|
121
|
+
f"[yellow]wup.web_client: {url} → HTTP {resp.status_code}[/yellow]"
|
|
122
|
+
)
|
|
123
|
+
return False
|
|
124
|
+
except Exception as exc: # noqa: BLE001 — soft-fail by design
|
|
125
|
+
_console.print(f"[yellow]wup.web_client: send_event failed ({exc})[/yellow]")
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
async def send_regression(
|
|
129
|
+
self,
|
|
130
|
+
service: str,
|
|
131
|
+
file: str,
|
|
132
|
+
endpoint: str,
|
|
133
|
+
reason: str,
|
|
134
|
+
stage: str = "quick",
|
|
135
|
+
) -> bool:
|
|
136
|
+
return await self.send_event({
|
|
137
|
+
"type": "REGRESSION",
|
|
138
|
+
"service": service,
|
|
139
|
+
"file": file,
|
|
140
|
+
"endpoint": endpoint,
|
|
141
|
+
"status": "fail",
|
|
142
|
+
"stage": stage,
|
|
143
|
+
"reason": reason,
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
async def send_pass(self, service: str, stage: str = "quick") -> bool:
|
|
147
|
+
return await self.send_event({
|
|
148
|
+
"type": "PASS",
|
|
149
|
+
"service": service,
|
|
150
|
+
"stage": stage,
|
|
151
|
+
"status": "ok",
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
async def send_health_transition(
|
|
155
|
+
self,
|
|
156
|
+
service: str,
|
|
157
|
+
from_status: str,
|
|
158
|
+
to_status: str,
|
|
159
|
+
) -> bool:
|
|
160
|
+
return await self.send_event({
|
|
161
|
+
"type": "HEALTH_TRANSITION",
|
|
162
|
+
"service": service,
|
|
163
|
+
"from": from_status,
|
|
164
|
+
"to": to_status,
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
async def send_visual_diff(
|
|
168
|
+
self,
|
|
169
|
+
service: str,
|
|
170
|
+
url: str,
|
|
171
|
+
diff: Dict[str, Any],
|
|
172
|
+
) -> bool:
|
|
173
|
+
return await self.send_event({
|
|
174
|
+
"type": "VISUAL_DIFF",
|
|
175
|
+
"service": service,
|
|
176
|
+
"url": url,
|
|
177
|
+
"diff": diff,
|
|
178
|
+
})
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared fixtures for wup-web tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from fastapi.testclient import TestClient
|
|
7
|
+
|
|
8
|
+
from wup_web.main import create_app
|
|
9
|
+
from wup_web.storage import EventStore, set_default_store
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def fresh_store():
|
|
14
|
+
store = EventStore(jsonl_path=None, capacity=100)
|
|
15
|
+
set_default_store(store)
|
|
16
|
+
yield store
|
|
17
|
+
set_default_store(EventStore(jsonl_path=None))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def client(fresh_store) -> TestClient:
|
|
22
|
+
app = create_app()
|
|
23
|
+
return TestClient(app)
|