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.
Files changed (42) hide show
  1. {wup-0.2.15/wup.egg-info → wup-0.2.16}/PKG-INFO +49 -7
  2. {wup-0.2.15 → wup-0.2.16}/README.md +48 -6
  3. {wup-0.2.15 → wup-0.2.16}/pyproject.toml +1 -1
  4. wup-0.2.16/tests/test_web_client.py +167 -0
  5. {wup-0.2.15 → wup-0.2.16}/wup/__init__.py +1 -1
  6. {wup-0.2.15 → wup-0.2.16}/wup/config.py +19 -0
  7. {wup-0.2.15 → wup-0.2.16}/wup/models/config.py +11 -0
  8. {wup-0.2.15 → wup-0.2.16}/wup/testql_watcher.py +16 -0
  9. wup-0.2.16/wup/web_client.py +178 -0
  10. wup-0.2.16/wup-web/tests/__init__.py +0 -0
  11. wup-0.2.16/wup-web/tests/conftest.py +23 -0
  12. wup-0.2.16/wup-web/tests/test_dashboard.py +35 -0
  13. wup-0.2.16/wup-web/tests/test_drivers.py +50 -0
  14. wup-0.2.16/wup-web/tests/test_events.py +95 -0
  15. wup-0.2.16/wup-web/wup_web/__init__.py +7 -0
  16. wup-0.2.16/wup-web/wup_web/__main__.py +21 -0
  17. wup-0.2.16/wup-web/wup_web/main.py +44 -0
  18. wup-0.2.16/wup-web/wup_web/models.py +59 -0
  19. wup-0.2.16/wup-web/wup_web/routers/__init__.py +1 -0
  20. wup-0.2.16/wup-web/wup_web/routers/dashboard.py +24 -0
  21. wup-0.2.16/wup-web/wup_web/routers/drivers.py +129 -0
  22. wup-0.2.16/wup-web/wup_web/routers/events.py +48 -0
  23. wup-0.2.16/wup-web/wup_web/storage.py +110 -0
  24. {wup-0.2.15 → wup-0.2.16/wup.egg-info}/PKG-INFO +49 -7
  25. wup-0.2.16/wup.egg-info/SOURCES.txt +38 -0
  26. wup-0.2.16/wup.egg-info/top_level.txt +2 -0
  27. wup-0.2.15/wup.egg-info/SOURCES.txt +0 -22
  28. wup-0.2.15/wup.egg-info/top_level.txt +0 -1
  29. {wup-0.2.15 → wup-0.2.16}/LICENSE +0 -0
  30. {wup-0.2.15 → wup-0.2.16}/setup.cfg +0 -0
  31. {wup-0.2.15 → wup-0.2.16}/tests/test_e2e.py +0 -0
  32. {wup-0.2.15 → wup-0.2.16}/tests/test_testql_watcher.py +0 -0
  33. {wup-0.2.15 → wup-0.2.16}/tests/test_wup.py +0 -0
  34. {wup-0.2.15 → wup-0.2.16}/wup/cli.py +0 -0
  35. {wup-0.2.15 → wup-0.2.16}/wup/core.py +0 -0
  36. {wup-0.2.15 → wup-0.2.16}/wup/dependency_mapper.py +0 -0
  37. {wup-0.2.15 → wup-0.2.16}/wup/models/__init__.py +0 -0
  38. {wup-0.2.15 → wup-0.2.16}/wup/testql_discovery.py +0 -0
  39. {wup-0.2.15 → wup-0.2.16}/wup/visual_diff.py +0 -0
  40. {wup-0.2.15 → wup-0.2.16}/wup.egg-info/dependency_links.txt +0 -0
  41. {wup-0.2.15 → wup-0.2.16}/wup.egg-info/entry_points.txt +0 -0
  42. {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.15
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.15-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
33
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.40-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-4.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
32
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.16-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
33
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.55-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-4.5h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
34
34
 
35
- - 🤖 **LLM usage:** $2.4000 (16 commits)
36
- - 👤 **Human dev:** ~$411 (4.1h @ $100/h, 30min dedup)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.15-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
42
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.16-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
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, TestQLConfig...
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.15-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.40-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-4.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.16-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.55-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-4.5h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $2.4000 (16 commits)
10
- - 👤 **Human dev:** ~$411 (4.1h @ $100/h, 30min dedup)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.15-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
16
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.16-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
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, TestQLConfig...
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wup"
7
- version = "0.2.15"
7
+ version = "0.2.16"
8
8
  description = "WUP (What's Up) - Intelligent file watcher for regression testing in large projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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.15"
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)