wupbro 0.1.2__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.
- wupbro-0.1.2/PKG-INFO +154 -0
- wupbro-0.1.2/README.md +132 -0
- wupbro-0.1.2/pyproject.toml +74 -0
- wupbro-0.1.2/setup.cfg +4 -0
- wupbro-0.1.2/tests/test_dashboard.py +35 -0
- wupbro-0.1.2/tests/test_drivers.py +50 -0
- wupbro-0.1.2/tests/test_events.py +95 -0
- wupbro-0.1.2/wupbro/__init__.py +7 -0
- wupbro-0.1.2/wupbro/__main__.py +21 -0
- wupbro-0.1.2/wupbro/main.py +44 -0
- wupbro-0.1.2/wupbro/models.py +59 -0
- wupbro-0.1.2/wupbro/routers/__init__.py +1 -0
- wupbro-0.1.2/wupbro/routers/dashboard.py +24 -0
- wupbro-0.1.2/wupbro/routers/drivers.py +129 -0
- wupbro-0.1.2/wupbro/routers/events.py +48 -0
- wupbro-0.1.2/wupbro/storage.py +110 -0
- wupbro-0.1.2/wupbro/templates/index.html +182 -0
- wupbro-0.1.2/wupbro.egg-info/PKG-INFO +154 -0
- wupbro-0.1.2/wupbro.egg-info/SOURCES.txt +21 -0
- wupbro-0.1.2/wupbro.egg-info/dependency_links.txt +1 -0
- wupbro-0.1.2/wupbro.egg-info/entry_points.txt +2 -0
- wupbro-0.1.2/wupbro.egg-info/requires.txt +13 -0
- wupbro-0.1.2/wupbro.egg-info/top_level.txt +1 -0
wupbro-0.1.2/PKG-INFO
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wupbro
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: WUP Browser Dashboard — FastAPI backend for WUP regression watcher
|
|
5
|
+
Author: WUP contributors
|
|
6
|
+
Author-email: Tom Sapletta <tom@sapletta.com>
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: fastapi>=0.110
|
|
11
|
+
Requires-Dist: uvicorn[standard]>=0.27
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Requires-Dist: pydantic>=2.0
|
|
14
|
+
Requires-Dist: jinja2>=3.1
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
18
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
19
|
+
Requires-Dist: goal>=2.1.0; extra == "dev"
|
|
20
|
+
Requires-Dist: costs>=0.1.20; extra == "dev"
|
|
21
|
+
Requires-Dist: pfix>=0.1.60; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# wupbro
|
|
24
|
+
|
|
25
|
+
FastAPI backend + minimal HTML dashboard for the WUP regression watcher.
|
|
26
|
+
|
|
27
|
+
## Architecture
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
┌──────────────────────┐ POST /events ┌──────────────────────┐
|
|
31
|
+
│ WUP agent (shell) │ ───────────────▶ │ wupbro (FastAPI) │
|
|
32
|
+
│ - file watcher │ │ - /events (sink) │
|
|
33
|
+
│ - testql runner │ │ - /drivers/* │
|
|
34
|
+
│ - visual diff │ │ - /dashboard (UI) │
|
|
35
|
+
└──────────────────────┘ └──────────────────────┘
|
|
36
|
+
│
|
|
37
|
+
▼
|
|
38
|
+
┌─────────────────┐
|
|
39
|
+
│ Browser UI │
|
|
40
|
+
│ (auto-refresh) │
|
|
41
|
+
└─────────────────┘
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Endpoints
|
|
45
|
+
|
|
46
|
+
| Path | Method | Purpose |
|
|
47
|
+
|-----------------------------------|--------|---------------------------------------------|
|
|
48
|
+
| `/events` | POST | Receive event from a WUP agent |
|
|
49
|
+
| `/events` | GET | List recent events (filter by type/service) |
|
|
50
|
+
| `/events/stats` | GET | Aggregate counts by type |
|
|
51
|
+
| `/events` | DELETE | Clear store (admin/debug) |
|
|
52
|
+
| `/drivers/dom-diff/capture` | POST | One-shot Playwright DOM snapshot + diff |
|
|
53
|
+
| `/drivers/browserless/screenshot` | POST | Proxy to a `browserless/chrome` container |
|
|
54
|
+
| `/drivers/anomaly/report` | POST | Record numeric anomaly as ANOMALY event |
|
|
55
|
+
| `/drivers/health` | GET | Driver capability discovery |
|
|
56
|
+
| `/`, `/dashboard` | GET | HTML dashboard |
|
|
57
|
+
| `/healthz` | GET | Liveness probe |
|
|
58
|
+
| `/openapi.json`, `/docs` | GET | OpenAPI spec + Swagger UI |
|
|
59
|
+
|
|
60
|
+
## Event types
|
|
61
|
+
|
|
62
|
+
`REGRESSION`, `PASS`, `ANOMALY`, `VISUAL_DIFF`, `HEALTH_TRANSITION`.
|
|
63
|
+
|
|
64
|
+
Schema (Pydantic):
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"type": "REGRESSION",
|
|
69
|
+
"service": "users-web",
|
|
70
|
+
"file": "app/users/routes.py",
|
|
71
|
+
"endpoint": "/api/users",
|
|
72
|
+
"status": "fail",
|
|
73
|
+
"stage": "quick",
|
|
74
|
+
"reason": "TestQL exit code 1",
|
|
75
|
+
"timestamp": 1730000000
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Extra fields are preserved via `model_config = {"extra": "allow"}`.
|
|
80
|
+
|
|
81
|
+
## Run
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# Install
|
|
85
|
+
pip install -e wupbro/
|
|
86
|
+
|
|
87
|
+
# Dev server (auto-reload)
|
|
88
|
+
wupbro --reload --port 8000
|
|
89
|
+
|
|
90
|
+
# Or directly
|
|
91
|
+
uvicorn wupbro.main:app --host 0.0.0.0 --port 8000 --reload
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Dashboard: <http://localhost:8000/>
|
|
95
|
+
|
|
96
|
+
OpenAPI docs: <http://localhost:8000/docs>
|
|
97
|
+
|
|
98
|
+
## Configure WUP agent to send events
|
|
99
|
+
|
|
100
|
+
In your `wup.yaml`:
|
|
101
|
+
|
|
102
|
+
```yaml
|
|
103
|
+
web:
|
|
104
|
+
enabled: true
|
|
105
|
+
endpoint: "http://localhost:8000"
|
|
106
|
+
endpoint_env: "WUPBRO_ENDPOINT" # fallback if endpoint is empty
|
|
107
|
+
timeout_s: 2.0 # short — must not block watcher
|
|
108
|
+
api_key: "" # optional bearer token
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Or via environment variable:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
export WUPBRO_ENDPOINT=http://localhost:8000
|
|
115
|
+
wup watch . --mode testql
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The agent will POST events fire-and-forget on:
|
|
119
|
+
|
|
120
|
+
- service health transitions (up ↔ down)
|
|
121
|
+
- regressions (when TestQL fails)
|
|
122
|
+
- visual DOM diffs (when significant changes detected)
|
|
123
|
+
|
|
124
|
+
## Browserless integration
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
docker run -d --name browserless -p 3000:3000 browserless/chrome
|
|
128
|
+
export BROWSERLESS_URL=http://localhost:3000
|
|
129
|
+
wup-web
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Then:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
curl -X POST http://localhost:8000/drivers/browserless/screenshot \
|
|
136
|
+
-H "Content-Type: application/json" \
|
|
137
|
+
-d '{"url": "http://example.com", "full_page": true}'
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Storage
|
|
141
|
+
|
|
142
|
+
Events are kept in an in-memory ring buffer (capacity 1000) and persisted to `.wupbro/events.jsonl` for restart durability.
|
|
143
|
+
|
|
144
|
+
## Tests
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
PYTHONPATH=wupbro python3 -m pytest wupbro/tests/ -v
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
17 tests covering events router, drivers (anomaly, browserless, dom-diff, health), dashboard HTML, OpenAPI schema.
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
Licensed under Apache-2.0.
|
wupbro-0.1.2/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# wupbro
|
|
2
|
+
|
|
3
|
+
FastAPI backend + minimal HTML dashboard for the WUP regression watcher.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌──────────────────────┐ POST /events ┌──────────────────────┐
|
|
9
|
+
│ WUP agent (shell) │ ───────────────▶ │ wupbro (FastAPI) │
|
|
10
|
+
│ - file watcher │ │ - /events (sink) │
|
|
11
|
+
│ - testql runner │ │ - /drivers/* │
|
|
12
|
+
│ - visual diff │ │ - /dashboard (UI) │
|
|
13
|
+
└──────────────────────┘ └──────────────────────┘
|
|
14
|
+
│
|
|
15
|
+
▼
|
|
16
|
+
┌─────────────────┐
|
|
17
|
+
│ Browser UI │
|
|
18
|
+
│ (auto-refresh) │
|
|
19
|
+
└─────────────────┘
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Endpoints
|
|
23
|
+
|
|
24
|
+
| Path | Method | Purpose |
|
|
25
|
+
|-----------------------------------|--------|---------------------------------------------|
|
|
26
|
+
| `/events` | POST | Receive event from a WUP agent |
|
|
27
|
+
| `/events` | GET | List recent events (filter by type/service) |
|
|
28
|
+
| `/events/stats` | GET | Aggregate counts by type |
|
|
29
|
+
| `/events` | DELETE | Clear store (admin/debug) |
|
|
30
|
+
| `/drivers/dom-diff/capture` | POST | One-shot Playwright DOM snapshot + diff |
|
|
31
|
+
| `/drivers/browserless/screenshot` | POST | Proxy to a `browserless/chrome` container |
|
|
32
|
+
| `/drivers/anomaly/report` | POST | Record numeric anomaly as ANOMALY event |
|
|
33
|
+
| `/drivers/health` | GET | Driver capability discovery |
|
|
34
|
+
| `/`, `/dashboard` | GET | HTML dashboard |
|
|
35
|
+
| `/healthz` | GET | Liveness probe |
|
|
36
|
+
| `/openapi.json`, `/docs` | GET | OpenAPI spec + Swagger UI |
|
|
37
|
+
|
|
38
|
+
## Event types
|
|
39
|
+
|
|
40
|
+
`REGRESSION`, `PASS`, `ANOMALY`, `VISUAL_DIFF`, `HEALTH_TRANSITION`.
|
|
41
|
+
|
|
42
|
+
Schema (Pydantic):
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"type": "REGRESSION",
|
|
47
|
+
"service": "users-web",
|
|
48
|
+
"file": "app/users/routes.py",
|
|
49
|
+
"endpoint": "/api/users",
|
|
50
|
+
"status": "fail",
|
|
51
|
+
"stage": "quick",
|
|
52
|
+
"reason": "TestQL exit code 1",
|
|
53
|
+
"timestamp": 1730000000
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Extra fields are preserved via `model_config = {"extra": "allow"}`.
|
|
58
|
+
|
|
59
|
+
## Run
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Install
|
|
63
|
+
pip install -e wupbro/
|
|
64
|
+
|
|
65
|
+
# Dev server (auto-reload)
|
|
66
|
+
wupbro --reload --port 8000
|
|
67
|
+
|
|
68
|
+
# Or directly
|
|
69
|
+
uvicorn wupbro.main:app --host 0.0.0.0 --port 8000 --reload
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Dashboard: <http://localhost:8000/>
|
|
73
|
+
|
|
74
|
+
OpenAPI docs: <http://localhost:8000/docs>
|
|
75
|
+
|
|
76
|
+
## Configure WUP agent to send events
|
|
77
|
+
|
|
78
|
+
In your `wup.yaml`:
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
web:
|
|
82
|
+
enabled: true
|
|
83
|
+
endpoint: "http://localhost:8000"
|
|
84
|
+
endpoint_env: "WUPBRO_ENDPOINT" # fallback if endpoint is empty
|
|
85
|
+
timeout_s: 2.0 # short — must not block watcher
|
|
86
|
+
api_key: "" # optional bearer token
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Or via environment variable:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
export WUPBRO_ENDPOINT=http://localhost:8000
|
|
93
|
+
wup watch . --mode testql
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The agent will POST events fire-and-forget on:
|
|
97
|
+
|
|
98
|
+
- service health transitions (up ↔ down)
|
|
99
|
+
- regressions (when TestQL fails)
|
|
100
|
+
- visual DOM diffs (when significant changes detected)
|
|
101
|
+
|
|
102
|
+
## Browserless integration
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
docker run -d --name browserless -p 3000:3000 browserless/chrome
|
|
106
|
+
export BROWSERLESS_URL=http://localhost:3000
|
|
107
|
+
wup-web
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Then:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
curl -X POST http://localhost:8000/drivers/browserless/screenshot \
|
|
114
|
+
-H "Content-Type: application/json" \
|
|
115
|
+
-d '{"url": "http://example.com", "full_page": true}'
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Storage
|
|
119
|
+
|
|
120
|
+
Events are kept in an in-memory ring buffer (capacity 1000) and persisted to `.wupbro/events.jsonl` for restart durability.
|
|
121
|
+
|
|
122
|
+
## Tests
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
PYTHONPATH=wupbro python3 -m pytest wupbro/tests/ -v
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
17 tests covering events router, drivers (anomaly, browserless, dom-diff, health), dashboard HTML, OpenAPI schema.
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
Licensed under Apache-2.0.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wupbro"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "WUP Browser Dashboard — FastAPI backend for WUP regression watcher"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
authors = [{ name = "WUP contributors" }, {name = "Tom Sapletta",email = "tom@sapletta.com"}]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"fastapi>=0.110",
|
|
14
|
+
"uvicorn[standard]>=0.27",
|
|
15
|
+
"httpx>=0.27",
|
|
16
|
+
"pydantic>=2.0",
|
|
17
|
+
"jinja2>=3.1",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.license]
|
|
21
|
+
text = "Apache-2.0"
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=8",
|
|
26
|
+
"pytest-asyncio>=0.23",
|
|
27
|
+
"httpx>=0.27",
|
|
28
|
+
"goal>=2.1.0",
|
|
29
|
+
"costs>=0.1.20",
|
|
30
|
+
"pfix>=0.1.60",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
wupbro = "wupbro.__main__:main"
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["."]
|
|
38
|
+
include = ["wupbro*"]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.package-data]
|
|
41
|
+
"wupbro" = ["templates/*.html", "static/*"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
asyncio_mode = "auto"
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
|
|
47
|
+
[tool.pfix]
|
|
48
|
+
# Self-healing Python configuration
|
|
49
|
+
model = "openrouter/qwen/qwen3-coder-next"
|
|
50
|
+
auto_apply = true
|
|
51
|
+
auto_install_deps = true
|
|
52
|
+
auto_restart = false
|
|
53
|
+
max_retries = 3
|
|
54
|
+
create_backups = false
|
|
55
|
+
git_auto_commit = false
|
|
56
|
+
|
|
57
|
+
[tool.pfix.runtime_todo]
|
|
58
|
+
enabled = true
|
|
59
|
+
todo_file = "TODO.md"
|
|
60
|
+
min_severity = "low"
|
|
61
|
+
deduplicate = true
|
|
62
|
+
|
|
63
|
+
[tool.costs]
|
|
64
|
+
# AI Cost tracking configuration
|
|
65
|
+
badge = true
|
|
66
|
+
update_readme = true
|
|
67
|
+
readme_path = "README.md"
|
|
68
|
+
default_model = "openrouter/qwen/qwen3-coder-next"
|
|
69
|
+
analysis_mode = "byok"
|
|
70
|
+
full_history = true
|
|
71
|
+
max_commits = 500
|
|
72
|
+
|
|
73
|
+
# Cost thresholds for badge colors (USD)
|
|
74
|
+
badge_color_thresholds = { low = 1.0, medium = 5.0, high = 10.0, critical = 50.0 }
|
wupbro-0.1.2/setup.cfg
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Tests for the dashboard HTML and meta endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_root_serves_dashboard(client):
|
|
7
|
+
resp = client.get("/")
|
|
8
|
+
assert resp.status_code == 200
|
|
9
|
+
assert resp.headers["content-type"].startswith("text/html")
|
|
10
|
+
assert "WUP Dashboard" in resp.text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_dashboard_alias(client):
|
|
14
|
+
resp = client.get("/dashboard")
|
|
15
|
+
assert resp.status_code == 200
|
|
16
|
+
assert "WUP Dashboard" in resp.text
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_healthz(client):
|
|
20
|
+
resp = client.get("/healthz")
|
|
21
|
+
assert resp.status_code == 200
|
|
22
|
+
body = resp.json()
|
|
23
|
+
assert body["ok"] is True
|
|
24
|
+
assert "version" in body
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_openapi_schema_includes_routes(client):
|
|
28
|
+
resp = client.get("/openapi.json")
|
|
29
|
+
assert resp.status_code == 200
|
|
30
|
+
spec = resp.json()
|
|
31
|
+
paths = spec["paths"]
|
|
32
|
+
assert "/events" in paths
|
|
33
|
+
assert "/events/stats" in paths
|
|
34
|
+
assert "/drivers/anomaly/report" in paths
|
|
35
|
+
assert "/drivers/health" in paths
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Tests for /drivers/* router."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_anomaly_report_creates_event(client):
|
|
7
|
+
resp = client.post("/drivers/anomaly/report", json={
|
|
8
|
+
"service": "billing",
|
|
9
|
+
"metric": "p95_latency",
|
|
10
|
+
"value": 1500.0,
|
|
11
|
+
"threshold": 1000.0,
|
|
12
|
+
})
|
|
13
|
+
assert resp.status_code == 201
|
|
14
|
+
assert resp.json()["accepted"] is True
|
|
15
|
+
|
|
16
|
+
events = client.get("/events").json()
|
|
17
|
+
assert events["total"] == 1
|
|
18
|
+
e = events["items"][0]
|
|
19
|
+
assert e["type"] == "ANOMALY"
|
|
20
|
+
assert e["service"] == "billing"
|
|
21
|
+
assert "p95_latency" in e["reason"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_driver_health_reports_capabilities(client):
|
|
25
|
+
resp = client.get("/drivers/health")
|
|
26
|
+
assert resp.status_code == 200
|
|
27
|
+
caps = resp.json()
|
|
28
|
+
assert caps["events"] is True
|
|
29
|
+
assert caps["anomaly"] is True
|
|
30
|
+
assert "dom_diff" in caps # bool depending on wup install
|
|
31
|
+
assert "playwright" in caps # bool depending on playwright install
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_browserless_unreachable_returns_503(client, monkeypatch):
|
|
35
|
+
monkeypatch.setenv("BROWSERLESS_URL", "http://127.0.0.1:1") # closed port
|
|
36
|
+
resp = client.post("/drivers/browserless/screenshot", json={"url": "http://example.com"})
|
|
37
|
+
assert resp.status_code == 503
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_dom_diff_endpoint_exists(client):
|
|
41
|
+
"""Endpoint accepts request payload (actual diff requires Playwright)."""
|
|
42
|
+
# Using a non-routable URL — VisualDiffer logs a warning and returns
|
|
43
|
+
# a result with status='error' but does NOT raise.
|
|
44
|
+
resp = client.post("/drivers/dom-diff/capture", json={
|
|
45
|
+
"url": "http://127.0.0.1:1/",
|
|
46
|
+
"service": "demo",
|
|
47
|
+
"max_depth": 3,
|
|
48
|
+
})
|
|
49
|
+
# Either 200 (gracefully degraded) or 503 (wup not installed). Both acceptable.
|
|
50
|
+
assert resp.status_code in (200, 503)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Tests for /events router and EventStore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_post_event_accepted(client):
|
|
7
|
+
resp = client.post("/events", json={
|
|
8
|
+
"type": "REGRESSION",
|
|
9
|
+
"service": "users",
|
|
10
|
+
"file": "app/users/routes.py",
|
|
11
|
+
"endpoint": "/api/users",
|
|
12
|
+
"status": "fail",
|
|
13
|
+
"reason": "boom",
|
|
14
|
+
})
|
|
15
|
+
assert resp.status_code == 201
|
|
16
|
+
body = resp.json()
|
|
17
|
+
assert body["accepted"] is True
|
|
18
|
+
assert body["type"] == "REGRESSION"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_post_event_invalid_type_rejected(client):
|
|
22
|
+
resp = client.post("/events", json={"type": "NOT_A_TYPE"})
|
|
23
|
+
assert resp.status_code == 422
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_list_events_returns_recent(client):
|
|
27
|
+
for i in range(3):
|
|
28
|
+
client.post("/events", json={"type": "PASS", "service": f"svc-{i}"})
|
|
29
|
+
resp = client.get("/events")
|
|
30
|
+
assert resp.status_code == 200
|
|
31
|
+
body = resp.json()
|
|
32
|
+
assert body["total"] == 3
|
|
33
|
+
services = [e["service"] for e in body["items"]]
|
|
34
|
+
# newest first
|
|
35
|
+
assert services == ["svc-2", "svc-1", "svc-0"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_list_events_filter_by_type(client):
|
|
39
|
+
client.post("/events", json={"type": "REGRESSION", "service": "a"})
|
|
40
|
+
client.post("/events", json={"type": "PASS", "service": "b"})
|
|
41
|
+
client.post("/events", json={"type": "REGRESSION", "service": "c"})
|
|
42
|
+
|
|
43
|
+
resp = client.get("/events?type=REGRESSION")
|
|
44
|
+
assert resp.status_code == 200
|
|
45
|
+
body = resp.json()
|
|
46
|
+
assert body["total"] == 2
|
|
47
|
+
assert all(e["type"] == "REGRESSION" for e in body["items"])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_list_events_filter_by_service(client):
|
|
51
|
+
client.post("/events", json={"type": "PASS", "service": "users"})
|
|
52
|
+
client.post("/events", json={"type": "PASS", "service": "billing"})
|
|
53
|
+
resp = client.get("/events?service=users")
|
|
54
|
+
body = resp.json()
|
|
55
|
+
assert body["total"] == 1
|
|
56
|
+
assert body["items"][0]["service"] == "users"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_list_events_limit(client):
|
|
60
|
+
for i in range(20):
|
|
61
|
+
client.post("/events", json={"type": "PASS", "service": f"s{i}"})
|
|
62
|
+
resp = client.get("/events?limit=5")
|
|
63
|
+
body = resp.json()
|
|
64
|
+
assert body["total"] == 5
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_event_stats(client):
|
|
68
|
+
client.post("/events", json={"type": "REGRESSION", "service": "x"})
|
|
69
|
+
client.post("/events", json={"type": "PASS", "service": "x"})
|
|
70
|
+
client.post("/events", json={"type": "PASS", "service": "y"})
|
|
71
|
+
resp = client.get("/events/stats")
|
|
72
|
+
assert resp.status_code == 200
|
|
73
|
+
stats = resp.json()
|
|
74
|
+
assert stats["total"] == 3
|
|
75
|
+
assert stats["by_type"]["REGRESSION"] == 1
|
|
76
|
+
assert stats["by_type"]["PASS"] == 2
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_clear_events(client):
|
|
80
|
+
client.post("/events", json={"type": "PASS", "service": "x"})
|
|
81
|
+
resp = client.delete("/events")
|
|
82
|
+
assert resp.status_code == 204
|
|
83
|
+
body = client.get("/events").json()
|
|
84
|
+
assert body["total"] == 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_event_extra_fields_preserved(client):
|
|
88
|
+
client.post("/events", json={
|
|
89
|
+
"type": "VISUAL_DIFF",
|
|
90
|
+
"service": "users",
|
|
91
|
+
"url": "http://localhost/users",
|
|
92
|
+
"diff": {"status": "changed", "counts": {"added": 5}},
|
|
93
|
+
})
|
|
94
|
+
body = client.get("/events").json()
|
|
95
|
+
assert body["items"][0]["diff"]["counts"]["added"] == 5
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""CLI entry-point: `python -m wupbro` or `wupbro`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
parser = argparse.ArgumentParser(prog="wupbro", description="WUP Browser Dashboard")
|
|
11
|
+
parser.add_argument("--host", default=os.environ.get("WUPBRO_HOST", "0.0.0.0"))
|
|
12
|
+
parser.add_argument("--port", type=int, default=int(os.environ.get("WUPBRO_PORT", "8000")))
|
|
13
|
+
parser.add_argument("--reload", action="store_true", help="auto-reload (dev)")
|
|
14
|
+
args = parser.parse_args()
|
|
15
|
+
|
|
16
|
+
import uvicorn
|
|
17
|
+
uvicorn.run("wupbro.main:app", host=args.host, port=args.port, reload=args.reload)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""FastAPI entry-point for wupbro."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .routers import dashboard, drivers, events
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_app() -> FastAPI:
|
|
13
|
+
app = FastAPI(
|
|
14
|
+
title="WUP Browser Dashboard",
|
|
15
|
+
version=__version__,
|
|
16
|
+
description=(
|
|
17
|
+
"Backend for the WUP regression watcher. Receives events from "
|
|
18
|
+
"WUP agents (REGRESSION, PASS, ANOMALY, VISUAL_DIFF, HEALTH_TRANSITION), "
|
|
19
|
+
"exposes drivers (DOM diff, browserless, anomaly), and serves a dashboard."
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Permissive CORS — dashboard usually served from same origin, but agents
|
|
24
|
+
# may live elsewhere. Tighten in production.
|
|
25
|
+
app.add_middleware(
|
|
26
|
+
CORSMiddleware,
|
|
27
|
+
allow_origins=["*"],
|
|
28
|
+
allow_methods=["*"],
|
|
29
|
+
allow_headers=["*"],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
app.include_router(events.router)
|
|
33
|
+
app.include_router(drivers.router)
|
|
34
|
+
app.include_router(dashboard.router)
|
|
35
|
+
|
|
36
|
+
@app.get("/healthz", tags=["meta"])
|
|
37
|
+
async def healthz():
|
|
38
|
+
return {"ok": True, "version": __version__}
|
|
39
|
+
|
|
40
|
+
return app
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Module-level instance for `uvicorn wupbro.main:app`
|
|
44
|
+
app = create_app()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Pydantic models for wupbro events and driver requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
EventType = Literal[
|
|
12
|
+
"REGRESSION",
|
|
13
|
+
"PASS",
|
|
14
|
+
"ANOMALY",
|
|
15
|
+
"VISUAL_DIFF",
|
|
16
|
+
"HEALTH_TRANSITION",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Event(BaseModel):
|
|
21
|
+
"""Generic WUP event posted by an agent."""
|
|
22
|
+
|
|
23
|
+
type: EventType
|
|
24
|
+
service: Optional[str] = None
|
|
25
|
+
file: Optional[str] = None
|
|
26
|
+
endpoint: Optional[str] = None
|
|
27
|
+
url: Optional[str] = None
|
|
28
|
+
status: Optional[str] = None
|
|
29
|
+
stage: Optional[str] = None
|
|
30
|
+
reason: Optional[str] = None
|
|
31
|
+
diff: Optional[Dict[str, Any]] = None
|
|
32
|
+
timestamp: int = Field(default_factory=lambda: int(time.time()))
|
|
33
|
+
|
|
34
|
+
# Forward-compat: allow extra keys to land in `extra`
|
|
35
|
+
model_config = {"extra": "allow"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EventList(BaseModel):
|
|
39
|
+
items: List[Event]
|
|
40
|
+
total: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DomDiffRequest(BaseModel):
|
|
44
|
+
url: str
|
|
45
|
+
service: str = "default"
|
|
46
|
+
max_depth: int = 10
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ScreenshotRequest(BaseModel):
|
|
50
|
+
url: str
|
|
51
|
+
full_page: bool = True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AnomalyReport(BaseModel):
|
|
55
|
+
service: str
|
|
56
|
+
metric: str
|
|
57
|
+
value: float
|
|
58
|
+
threshold: float
|
|
59
|
+
timestamp: int = Field(default_factory=lambda: int(time.time()))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Routers for wupbro."""
|