wup 0.2.26__tar.gz → 0.2.28__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.26/wup.egg-info → wup-0.2.28}/PKG-INFO +14 -13
- {wup-0.2.26 → wup-0.2.28}/README.md +13 -12
- {wup-0.2.26 → wup-0.2.28}/pyproject.toml +1 -1
- {wup-0.2.26 → wup-0.2.28}/tests/test_testql_monitor.py +47 -0
- {wup-0.2.26 → wup-0.2.28}/tests/test_testql_watcher.py +80 -1
- {wup-0.2.26 → wup-0.2.28}/wup/__init__.py +1 -1
- {wup-0.2.26 → wup-0.2.28}/wup/cli.py +21 -10
- {wup-0.2.26 → wup-0.2.28}/wup/config.py +10 -2
- {wup-0.2.26 → wup-0.2.28}/wup/models/config.py +7 -2
- {wup-0.2.26 → wup-0.2.28}/wup/testql_monitor.py +111 -20
- {wup-0.2.26 → wup-0.2.28}/wup/testql_watcher.py +126 -18
- {wup-0.2.26 → wup-0.2.28}/wup/visual_diff.py +26 -3
- {wup-0.2.26 → wup-0.2.28}/wup/web_client.py +8 -1
- {wup-0.2.26 → wup-0.2.28/wup.egg-info}/PKG-INFO +14 -13
- {wup-0.2.26 → wup-0.2.28}/LICENSE +0 -0
- {wup-0.2.26 → wup-0.2.28}/setup.cfg +0 -0
- {wup-0.2.26 → wup-0.2.28}/tests/test_e2e.py +0 -0
- {wup-0.2.26 → wup-0.2.28}/tests/test_monitoring_manifest.py +0 -0
- {wup-0.2.26 → wup-0.2.28}/tests/test_web_client.py +0 -0
- {wup-0.2.26 → wup-0.2.28}/tests/test_wup.py +0 -0
- {wup-0.2.26 → wup-0.2.28}/wup/_ast_detector.py +0 -0
- {wup-0.2.26 → wup-0.2.28}/wup/_hash_detector.py +0 -0
- {wup-0.2.26 → wup-0.2.28}/wup/_yaml_detector.py +0 -0
- {wup-0.2.26 → wup-0.2.28}/wup/anomaly_detector.py +0 -0
- {wup-0.2.26 → wup-0.2.28}/wup/anomaly_models.py +0 -0
- {wup-0.2.26 → wup-0.2.28}/wup/assistant.py +0 -0
- {wup-0.2.26 → wup-0.2.28}/wup/core.py +0 -0
- {wup-0.2.26 → wup-0.2.28}/wup/dependency_mapper.py +0 -0
- {wup-0.2.26 → wup-0.2.28}/wup/models/__init__.py +0 -0
- {wup-0.2.26 → wup-0.2.28}/wup/monitoring_manifest.py +0 -0
- {wup-0.2.26 → wup-0.2.28}/wup/testql_discovery.py +0 -0
- {wup-0.2.26 → wup-0.2.28}/wup.egg-info/SOURCES.txt +0 -0
- {wup-0.2.26 → wup-0.2.28}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.26 → wup-0.2.28}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.26 → wup-0.2.28}/wup.egg-info/requires.txt +0 -0
- {wup-0.2.26 → wup-0.2.28}/wup.egg-info/top_level.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.28
|
|
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-Expression: 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:** $
|
|
36
|
-
- 👤 **Human dev:** ~$
|
|
35
|
+
- 🤖 **LLM usage:** $2.2653 (38 commits)
|
|
36
|
+
- 👤 **Human dev:** ~$1632 (16.3h @ $100/h, 30min dedup)
|
|
37
37
|
|
|
38
|
-
Generated on 2026-05-
|
|
38
|
+
Generated on 2026-05-17 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
|
|
|
@@ -83,7 +83,7 @@ wup assistant --quick --template fastapi
|
|
|
83
83
|
# 3. Build dependency map (one-time setup)
|
|
84
84
|
wup map-deps ./my-project
|
|
85
85
|
|
|
86
|
-
# 4. Start watching
|
|
86
|
+
# 4. Start watching (TestQL + live probes every 60s by default)
|
|
87
87
|
wup watch ./my-project
|
|
88
88
|
|
|
89
89
|
# 5. Start with live dashboard
|
|
@@ -110,14 +110,18 @@ wup map-deps ./my-project --output my-deps.json
|
|
|
110
110
|
### Watch Project
|
|
111
111
|
|
|
112
112
|
```bash
|
|
113
|
-
# Basic watching (uses wup.yaml if present)
|
|
113
|
+
# Basic watching: TestQL mode + live HTTP probes every 60s (uses wup.yaml if present)
|
|
114
114
|
wup watch ./my-project
|
|
115
115
|
|
|
116
|
+
# Legacy HTTP-only watcher (no TestQL, no periodic probes unless configured)
|
|
117
|
+
wup watch ./my-project --mode default --probe-interval 0
|
|
118
|
+
|
|
116
119
|
# With custom settings
|
|
117
120
|
wup watch ./my-project \
|
|
118
121
|
--cpu-throttle 0.5 \
|
|
119
122
|
--debounce 3 \
|
|
120
|
-
--cooldown 600
|
|
123
|
+
--cooldown 600 \
|
|
124
|
+
--probe-interval 120
|
|
121
125
|
|
|
122
126
|
# With live dashboard
|
|
123
127
|
wup watch ./my-project --dashboard
|
|
@@ -125,9 +129,6 @@ wup watch ./my-project --dashboard
|
|
|
125
129
|
# Use specific config file
|
|
126
130
|
wup watch ./my-project --config custom-config.yaml
|
|
127
131
|
|
|
128
|
-
# TestQL mode
|
|
129
|
-
wup watch ./my-project --mode testql
|
|
130
|
-
|
|
131
132
|
# Discover endpoints from TestQL scenarios
|
|
132
133
|
wup testql-endpoints /path/to/scenarios --output testql-deps.json
|
|
133
134
|
```
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
    
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $2.2653 (38 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$1632 (16.3h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
|
-
Generated on 2026-05-
|
|
12
|
+
Generated on 2026-05-17 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
|
|
|
@@ -57,7 +57,7 @@ wup assistant --quick --template fastapi
|
|
|
57
57
|
# 3. Build dependency map (one-time setup)
|
|
58
58
|
wup map-deps ./my-project
|
|
59
59
|
|
|
60
|
-
# 4. Start watching
|
|
60
|
+
# 4. Start watching (TestQL + live probes every 60s by default)
|
|
61
61
|
wup watch ./my-project
|
|
62
62
|
|
|
63
63
|
# 5. Start with live dashboard
|
|
@@ -84,14 +84,18 @@ wup map-deps ./my-project --output my-deps.json
|
|
|
84
84
|
### Watch Project
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
|
-
# Basic watching (uses wup.yaml if present)
|
|
87
|
+
# Basic watching: TestQL mode + live HTTP probes every 60s (uses wup.yaml if present)
|
|
88
88
|
wup watch ./my-project
|
|
89
89
|
|
|
90
|
+
# Legacy HTTP-only watcher (no TestQL, no periodic probes unless configured)
|
|
91
|
+
wup watch ./my-project --mode default --probe-interval 0
|
|
92
|
+
|
|
90
93
|
# With custom settings
|
|
91
94
|
wup watch ./my-project \
|
|
92
95
|
--cpu-throttle 0.5 \
|
|
93
96
|
--debounce 3 \
|
|
94
|
-
--cooldown 600
|
|
97
|
+
--cooldown 600 \
|
|
98
|
+
--probe-interval 120
|
|
95
99
|
|
|
96
100
|
# With live dashboard
|
|
97
101
|
wup watch ./my-project --dashboard
|
|
@@ -99,9 +103,6 @@ wup watch ./my-project --dashboard
|
|
|
99
103
|
# Use specific config file
|
|
100
104
|
wup watch ./my-project --config custom-config.yaml
|
|
101
105
|
|
|
102
|
-
# TestQL mode
|
|
103
|
-
wup watch ./my-project --mode testql
|
|
104
|
-
|
|
105
106
|
# Discover endpoints from TestQL scenarios
|
|
106
107
|
wup testql-endpoints /path/to/scenarios --output testql-deps.json
|
|
107
108
|
```
|
|
@@ -34,6 +34,31 @@ API[1]{method, endpoint, expected_status}:
|
|
|
34
34
|
assert is_monitoring_probe(probes[0])
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
def test_firmware_plugin_health_on_8202_not_live_probe():
|
|
38
|
+
probe = ProbeTarget(url="http://localhost:8202/api/v1/plugins/modbus-io/health")
|
|
39
|
+
assert not is_monitoring_probe(probe)
|
|
40
|
+
direct = ProbeTarget(url="http://localhost:8202/health")
|
|
41
|
+
assert is_monitoring_probe(direct)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_connect_api_paths_on_8100_are_not_monitoring_probes():
|
|
45
|
+
probe = ProbeTarget(url="http://localhost:8100/api/id/health")
|
|
46
|
+
assert not is_monitoring_probe(probe)
|
|
47
|
+
assert assign_probe_to_service(
|
|
48
|
+
probe,
|
|
49
|
+
[ServiceConfig(name="backend", paths=["backend/**", "api/**"])],
|
|
50
|
+
) != "backend"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_connect_health_on_8103_not_assigned_to_backend():
|
|
54
|
+
services = [
|
|
55
|
+
ServiceConfig(name="frontend", paths=["frontend/**"]),
|
|
56
|
+
ServiceConfig(name="backend", paths=["backend/**"]),
|
|
57
|
+
]
|
|
58
|
+
probe = ProbeTarget(url="http://localhost:8103/api/id/health")
|
|
59
|
+
assert assign_probe_to_service(probe, services) is None
|
|
60
|
+
|
|
61
|
+
|
|
37
62
|
def test_assign_firmware_service():
|
|
38
63
|
services = [
|
|
39
64
|
ServiceConfig(name="frontend", paths=["frontend/**"]),
|
|
@@ -87,6 +112,28 @@ def test_monitor_merges_config_and_service_map():
|
|
|
87
112
|
assert "http://localhost:8100/firmware/api/v1/execution/logs" in urls
|
|
88
113
|
|
|
89
114
|
|
|
115
|
+
def test_probes_for_service_ignores_non_health_extra_paths():
|
|
116
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
117
|
+
root = Path(tmpdir)
|
|
118
|
+
cfg = WupConfig(
|
|
119
|
+
project=ProjectConfig(name="demo"),
|
|
120
|
+
services=[ServiceConfig(name="backend", paths=["backend/**"])],
|
|
121
|
+
watch=WatchConfig(),
|
|
122
|
+
testql=TestQLConfig(
|
|
123
|
+
base_url="http://localhost:8100",
|
|
124
|
+
api_base_url="http://localhost:8101",
|
|
125
|
+
endpoints_by_service={"backend": ["http://localhost:8101/api/v3/health"]},
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
monitor = TestQLMonitor(root, cfg)
|
|
129
|
+
probes = monitor.probes_for_service(
|
|
130
|
+
"backend",
|
|
131
|
+
["/connect-config", "http://localhost:8101/connect-config"],
|
|
132
|
+
)
|
|
133
|
+
urls = {p.url for p in probes}
|
|
134
|
+
assert urls == {"http://localhost:8101/api/v3/health"}
|
|
135
|
+
|
|
136
|
+
|
|
90
137
|
def test_live_probe_failure_updates_health():
|
|
91
138
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
92
139
|
root = Path(tmpdir)
|
|
@@ -6,7 +6,14 @@ from pathlib import Path
|
|
|
6
6
|
from subprocess import CompletedProcess
|
|
7
7
|
|
|
8
8
|
from wup.testql_watcher import TestQLWatcher
|
|
9
|
-
from wup.models.config import
|
|
9
|
+
from wup.models.config import (
|
|
10
|
+
ProjectConfig,
|
|
11
|
+
ServiceConfig,
|
|
12
|
+
TestQLConfig,
|
|
13
|
+
VisualDiffConfig,
|
|
14
|
+
WatchConfig,
|
|
15
|
+
WupConfig,
|
|
16
|
+
)
|
|
10
17
|
|
|
11
18
|
|
|
12
19
|
def test_process_changed_file_creates_track_on_failure():
|
|
@@ -97,6 +104,7 @@ def test_config_endpoints_use_base_url_from_yaml_config():
|
|
|
97
104
|
root = Path(tmpdir)
|
|
98
105
|
cfg = WupConfig(
|
|
99
106
|
project=ProjectConfig(name="demo"),
|
|
107
|
+
services=[ServiceConfig(name="connect-config", paths=["connect-config/**"])],
|
|
100
108
|
testql=TestQLConfig(
|
|
101
109
|
base_url="http://localhost:8100",
|
|
102
110
|
explicit_endpoints=["/connect-config"],
|
|
@@ -122,6 +130,7 @@ def test_config_endpoints_use_base_url_from_env_when_yaml_missing():
|
|
|
122
130
|
root = Path(tmpdir)
|
|
123
131
|
cfg = WupConfig(
|
|
124
132
|
project=ProjectConfig(name="demo"),
|
|
133
|
+
services=[ServiceConfig(name="connect-data", paths=["connect-data/**"])],
|
|
125
134
|
testql=TestQLConfig(
|
|
126
135
|
base_url="",
|
|
127
136
|
base_url_env="WUP_BASE_URL",
|
|
@@ -201,6 +210,76 @@ def test_service_health_transitions_are_persisted():
|
|
|
201
210
|
assert "up" in statuses
|
|
202
211
|
|
|
203
212
|
|
|
213
|
+
def test_normalize_fleet_health_entry_down_to_degraded():
|
|
214
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
215
|
+
root = Path(tmpdir)
|
|
216
|
+
health_path = root / ".wup" / "service-health.json"
|
|
217
|
+
health_path.parent.mkdir(parents=True)
|
|
218
|
+
health_path.write_text(
|
|
219
|
+
json.dumps(
|
|
220
|
+
{
|
|
221
|
+
"demo": {
|
|
222
|
+
"status": "down",
|
|
223
|
+
"stage": "health_scenario",
|
|
224
|
+
"message": "partial",
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
),
|
|
228
|
+
encoding="utf-8",
|
|
229
|
+
)
|
|
230
|
+
cfg = WupConfig(
|
|
231
|
+
project=ProjectConfig(name="demo"),
|
|
232
|
+
services=[ServiceConfig(name="frontend", paths=["frontend/**"])],
|
|
233
|
+
watch=WatchConfig(),
|
|
234
|
+
testql=TestQLConfig(health_scenario_strict=False),
|
|
235
|
+
)
|
|
236
|
+
TestQLWatcher(
|
|
237
|
+
project_root=str(root),
|
|
238
|
+
deps_file=str(root / "deps.json"),
|
|
239
|
+
scenarios_dir="testql-scenarios",
|
|
240
|
+
track_dir=".wup/tracks",
|
|
241
|
+
config=cfg,
|
|
242
|
+
)
|
|
243
|
+
state = json.loads(health_path.read_text(encoding="utf-8"))
|
|
244
|
+
assert state["demo"]["status"] == "degraded"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_fleet_health_scenario_non_strict_records_degraded_not_down():
|
|
248
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
249
|
+
root = Path(tmpdir)
|
|
250
|
+
scenario_dir = root / "testql-scenarios"
|
|
251
|
+
scenario_dir.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
(scenario_dir / "fleet.testql.toon.yaml").write_text("name: fleet\n", encoding="utf-8")
|
|
253
|
+
|
|
254
|
+
cfg = WupConfig(
|
|
255
|
+
project=ProjectConfig(name="demo"),
|
|
256
|
+
services=[ServiceConfig(name="frontend", paths=["frontend/**"])],
|
|
257
|
+
watch=WatchConfig(),
|
|
258
|
+
testql=TestQLConfig(
|
|
259
|
+
scenario_dir="testql-scenarios",
|
|
260
|
+
health_scenario="fleet.testql.toon.yaml",
|
|
261
|
+
health_scenario_strict=False,
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
watcher = TestQLWatcher(
|
|
265
|
+
project_root=str(root),
|
|
266
|
+
deps_file=str(root / "deps.json"),
|
|
267
|
+
scenarios_dir="testql-scenarios",
|
|
268
|
+
track_dir=".wup/tracks",
|
|
269
|
+
config=cfg,
|
|
270
|
+
)
|
|
271
|
+
watcher._run_testql = lambda args, timeout: CompletedProcess( # type: ignore[method-assign]
|
|
272
|
+
args=args,
|
|
273
|
+
returncode=1,
|
|
274
|
+
stdout='{"passed": 1, "failed": 1, "errors": ["L1: bad"]}',
|
|
275
|
+
stderr="",
|
|
276
|
+
)
|
|
277
|
+
assert asyncio.run(watcher._run_fleet_health_scenario()) is True
|
|
278
|
+
state = json.loads((root / ".wup" / "service-health.json").read_text(encoding="utf-8"))
|
|
279
|
+
assert state["demo"]["status"] == "degraded"
|
|
280
|
+
assert state["demo"]["stage"] == "health_scenario"
|
|
281
|
+
|
|
282
|
+
|
|
204
283
|
def test_visual_differ_disabled_by_default():
|
|
205
284
|
"""visual_differ exists but is disabled (no-op) when visual_diff.enabled=False."""
|
|
206
285
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
@@ -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.28"
|
|
11
11
|
__author__ = "Tom Sapletta"
|
|
12
12
|
|
|
13
13
|
from .config import load_config, save_config, get_default_config
|
|
@@ -31,8 +31,16 @@ def watch(
|
|
|
31
31
|
debounce: int = typer.Option(2, "--debounce", "-b", help="Debounce time in seconds"),
|
|
32
32
|
cooldown: int = typer.Option(300, "--cooldown", "-t", help="Test cooldown in seconds"),
|
|
33
33
|
dashboard: bool = typer.Option(False, "--dashboard", help="Enable live dashboard"),
|
|
34
|
-
mode: str = typer.Option(
|
|
35
|
-
|
|
34
|
+
mode: str = typer.Option(
|
|
35
|
+
"testql",
|
|
36
|
+
"--mode",
|
|
37
|
+
help="Watcher mode: testql (default) or default (HTTP-only, no TestQL)",
|
|
38
|
+
),
|
|
39
|
+
scenarios_dir: Optional[str] = typer.Option(
|
|
40
|
+
None,
|
|
41
|
+
"--scenarios-dir",
|
|
42
|
+
help="TestQL scenarios directory (default: testql.scenario_dir from wup.yaml)",
|
|
43
|
+
),
|
|
36
44
|
testql_bin: str = typer.Option("testql", "--testql-bin", help="TestQL executable name/path"),
|
|
37
45
|
browser_service_url: Optional[str] = typer.Option(None, "--browser-service-url", help="HTTP endpoint for browser notifications"),
|
|
38
46
|
track_dir: str = typer.Option(".wup/tracks", "--track-dir", help="Directory where error track JSON files are written"),
|
|
@@ -40,17 +48,16 @@ def watch(
|
|
|
40
48
|
probe_interval: Optional[int] = typer.Option(
|
|
41
49
|
None,
|
|
42
50
|
"--probe-interval",
|
|
43
|
-
help="Periodic live HTTP
|
|
51
|
+
help="Periodic live HTTP probes in seconds (default: 60 in testql mode, or testql.probe_interval_s from wup.yaml; use 0 to disable)",
|
|
44
52
|
),
|
|
45
53
|
config: Optional[str] = typer.Option(None, "--config", "-C", help="Path to wup.yaml config file"),
|
|
46
54
|
):
|
|
47
55
|
"""
|
|
48
|
-
Watch project for file changes and run
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
3. Detail: Full tests with blame reports (only on failure)
|
|
56
|
+
Watch project for file changes and run regression tests.
|
|
57
|
+
|
|
58
|
+
Defaults (no extra flags): ``--mode testql`` and live probes every **60s**
|
|
59
|
+
(unless ``testql.probe_interval_s`` is set in wup.yaml). Use
|
|
60
|
+
``--mode default`` for the legacy HTTP-only watcher without TestQL.
|
|
54
61
|
"""
|
|
55
62
|
project_path = Path(project).resolve()
|
|
56
63
|
|
|
@@ -63,6 +70,10 @@ def watch(
|
|
|
63
70
|
wup_config = load_config(project_path, config_path)
|
|
64
71
|
if probe_interval is not None:
|
|
65
72
|
wup_config.testql.probe_interval_s = int(probe_interval)
|
|
73
|
+
elif mode.lower() == "testql" and not wup_config.testql.probe_interval_s:
|
|
74
|
+
wup_config.testql.probe_interval_s = 60
|
|
75
|
+
|
|
76
|
+
effective_scenarios_dir = scenarios_dir or wup_config.testql.scenario_dir
|
|
66
77
|
|
|
67
78
|
console.print(f"[bold cyan]🚀 WUP Watcher[/bold cyan]")
|
|
68
79
|
console.print(f"[dim]Project: {wup_config.project.name}[/dim]")
|
|
@@ -92,7 +103,7 @@ def watch(
|
|
|
92
103
|
cpu_throttle=cpu_throttle,
|
|
93
104
|
debounce_seconds=debounce,
|
|
94
105
|
test_cooldown_seconds=cooldown,
|
|
95
|
-
scenarios_dir=
|
|
106
|
+
scenarios_dir=effective_scenarios_dir,
|
|
96
107
|
testql_bin=testql_bin,
|
|
97
108
|
browser_service_url=browser_service_url,
|
|
98
109
|
track_dir=track_dir,
|
|
@@ -178,9 +178,12 @@ def validate_config(raw: dict) -> WupConfig:
|
|
|
178
178
|
endpoint_discovery=testql_raw.get("endpoint_discovery", True),
|
|
179
179
|
probe_interval_s=int(testql_raw.get("probe_interval_s", 0) or 0),
|
|
180
180
|
health_scenario=testql_raw.get("health_scenario", ""),
|
|
181
|
+
health_scenario_strict=bool(testql_raw.get("health_scenario_strict", False)),
|
|
181
182
|
service_map_globs=testql_raw.get("service_map_globs", []),
|
|
182
183
|
base_url=testql_raw.get("base_url", ""),
|
|
184
|
+
api_base_url=testql_raw.get("api_base_url", ""),
|
|
183
185
|
base_url_env=testql_raw.get("base_url_env", "WUP_BASE_URL"),
|
|
186
|
+
service_base_urls=testql_raw.get("service_base_urls", {}),
|
|
184
187
|
explicit_endpoints=testql_raw.get("explicit_endpoints", []),
|
|
185
188
|
endpoints_by_service=testql_raw.get("endpoints_by_service", {})
|
|
186
189
|
)
|
|
@@ -215,7 +218,8 @@ def validate_config(raw: dict) -> WupConfig:
|
|
|
215
218
|
snapshot_dir=vd_raw.get("snapshot_dir", ".wup/visual-snapshots"),
|
|
216
219
|
diff_dir=vd_raw.get("diff_dir", ".wup/visual-diffs"),
|
|
217
220
|
pages=vd_raw.get("pages", []),
|
|
218
|
-
pages_from_endpoints=vd_raw.get("pages_from_endpoints",
|
|
221
|
+
pages_from_endpoints=vd_raw.get("pages_from_endpoints", False),
|
|
222
|
+
max_pages=int(vd_raw.get("max_pages", 5)),
|
|
219
223
|
threshold_added=int(vd_raw.get("threshold_added", 3)),
|
|
220
224
|
threshold_removed=int(vd_raw.get("threshold_removed", 3)),
|
|
221
225
|
threshold_changed=int(vd_raw.get("threshold_changed", 5)),
|
|
@@ -304,7 +308,7 @@ def save_config(config: WupConfig, output_path: Path):
|
|
|
304
308
|
f"# wupbro (optional dashboard): pip install wupbro",
|
|
305
309
|
f"#",
|
|
306
310
|
f"# Quick Start:",
|
|
307
|
-
f"# 1. wup watch . #
|
|
311
|
+
f"# 1. wup watch . # TestQL + live probes every 60s",
|
|
308
312
|
f"# 2. wup watch . --dashboard # With live dashboard",
|
|
309
313
|
f"# 3. wup map-deps . # Build dependency map",
|
|
310
314
|
f"#",
|
|
@@ -333,9 +337,12 @@ def save_config(config: WupConfig, output_path: Path):
|
|
|
333
337
|
"endpoint_discovery": config.testql.endpoint_discovery,
|
|
334
338
|
"probe_interval_s": config.testql.probe_interval_s,
|
|
335
339
|
"health_scenario": config.testql.health_scenario,
|
|
340
|
+
"health_scenario_strict": config.testql.health_scenario_strict,
|
|
336
341
|
"service_map_globs": config.testql.service_map_globs,
|
|
337
342
|
"base_url": config.testql.base_url,
|
|
343
|
+
"api_base_url": config.testql.api_base_url,
|
|
338
344
|
"base_url_env": config.testql.base_url_env,
|
|
345
|
+
"service_base_urls": config.testql.service_base_urls,
|
|
339
346
|
"explicit_endpoints": config.testql.explicit_endpoints,
|
|
340
347
|
"endpoints_by_service": config.testql.endpoints_by_service,
|
|
341
348
|
},
|
|
@@ -349,6 +356,7 @@ def save_config(config: WupConfig, output_path: Path):
|
|
|
349
356
|
"diff_dir": config.visual_diff.diff_dir,
|
|
350
357
|
"pages": config.visual_diff.pages,
|
|
351
358
|
"pages_from_endpoints": config.visual_diff.pages_from_endpoints,
|
|
359
|
+
"max_pages": config.visual_diff.max_pages,
|
|
352
360
|
"threshold_added": config.visual_diff.threshold_added,
|
|
353
361
|
"threshold_removed": config.visual_diff.threshold_removed,
|
|
354
362
|
"threshold_changed": config.visual_diff.threshold_changed,
|
|
@@ -56,16 +56,20 @@ class TestStrategyConfig:
|
|
|
56
56
|
@dataclass
|
|
57
57
|
class TestQLConfig:
|
|
58
58
|
"""TestQL-specific configuration."""
|
|
59
|
+
__test__ = False
|
|
59
60
|
scenario_dir: str = "scenarios/tests"
|
|
60
61
|
smoke_scenario: str = "smoke.testql.toon.yaml"
|
|
61
62
|
output_format: str = "json"
|
|
62
63
|
extra_args: List[str] = field(default_factory=lambda: ["--timeout 10s"])
|
|
63
64
|
endpoint_discovery: bool = True # Merge health probes from scenarios + service maps
|
|
64
65
|
probe_interval_s: int = 0 # Periodic live probes for all services (0 = file-change only)
|
|
65
|
-
health_scenario: str = "" #
|
|
66
|
+
health_scenario: str = "" # Fleet TestQL scenario on each periodic probe cycle (live run)
|
|
67
|
+
health_scenario_strict: bool = False # If false, fleet scenario failure is logged but does not block per-service probes
|
|
66
68
|
service_map_globs: List[str] = field(default_factory=list) # e.g. testql-testing/service-map/*.yaml
|
|
67
69
|
base_url: str = ""
|
|
70
|
+
api_base_url: str = "" # Core API (c2004: http://localhost:8101) — used for backend probes
|
|
68
71
|
base_url_env: str = "WUP_BASE_URL"
|
|
72
|
+
service_base_urls: Dict[str, str] = field(default_factory=dict) # optional per-service override
|
|
69
73
|
explicit_endpoints: List[str] = field(default_factory=list)
|
|
70
74
|
endpoints_by_service: Dict[str, List[str]] = field(default_factory=dict)
|
|
71
75
|
|
|
@@ -81,7 +85,8 @@ class VisualDiffConfig:
|
|
|
81
85
|
snapshot_dir: str = ".wup/visual-snapshots"
|
|
82
86
|
diff_dir: str = ".wup/visual-diffs"
|
|
83
87
|
pages: List[str] = field(default_factory=list) # explicit page paths to scan
|
|
84
|
-
pages_from_endpoints: bool = True
|
|
88
|
+
pages_from_endpoints: bool = True
|
|
89
|
+
max_pages: int = 5 # cap DOM scans per service per file change
|
|
85
90
|
threshold_added: int = 3 # min added nodes to report
|
|
86
91
|
threshold_removed: int = 3 # min removed nodes to report
|
|
87
92
|
threshold_changed: int = 5 # min changed attrs to report
|
|
@@ -22,6 +22,18 @@ _HEALTH_HINT = re.compile(
|
|
|
22
22
|
r"(/health|/healthz|/ready|/live|/status|/openapi\.json|/execution/status|/execution/logs)",
|
|
23
23
|
re.IGNORECASE,
|
|
24
24
|
)
|
|
25
|
+
# Connect module APIs live on :8103+ — not valid health probes on frontend proxy :8100
|
|
26
|
+
_CONNECT_API_PREFIXES = (
|
|
27
|
+
"/api/id",
|
|
28
|
+
"/api/manager",
|
|
29
|
+
"/api/scenario",
|
|
30
|
+
"/api/test",
|
|
31
|
+
"/api/template",
|
|
32
|
+
"/api/cql",
|
|
33
|
+
"/api/v1/data",
|
|
34
|
+
"/api/v2/menu",
|
|
35
|
+
)
|
|
36
|
+
_PATH_TOKEN_BLOCKLIST = frozenset({"api", "app", "src", "lib", "bin", "dist", "out"})
|
|
25
37
|
|
|
26
38
|
|
|
27
39
|
@dataclass(frozen=True)
|
|
@@ -102,12 +114,41 @@ def parse_service_map_probes(map_path: Path) -> List[ProbeTarget]:
|
|
|
102
114
|
return probes
|
|
103
115
|
|
|
104
116
|
|
|
117
|
+
def _connect_module_api_on_frontend_proxy(probe: ProbeTarget) -> bool:
|
|
118
|
+
"""True when a connect-* API path would be wrongly probed via :8100."""
|
|
119
|
+
if not probe.url.startswith("http"):
|
|
120
|
+
return False
|
|
121
|
+
parsed = urlparse(probe.url)
|
|
122
|
+
if parsed.port not in (None, 8100):
|
|
123
|
+
return False
|
|
124
|
+
path = (parsed.path or "").lower()
|
|
125
|
+
return any(path.startswith(prefix) for prefix in _CONNECT_API_PREFIXES)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _firmware_plugin_probe_without_runtime(probe: ProbeTarget) -> bool:
|
|
129
|
+
"""Plugin health on :8202 requires loaded plugins — skip for bare simulator live probes."""
|
|
130
|
+
if not probe.url.startswith("http"):
|
|
131
|
+
return False
|
|
132
|
+
parsed = urlparse(probe.url)
|
|
133
|
+
if parsed.port != 8202:
|
|
134
|
+
return False
|
|
135
|
+
path = (parsed.path or "").lower()
|
|
136
|
+
return "/api/v1/plugins/" in path and path.endswith("/health")
|
|
137
|
+
|
|
138
|
+
|
|
105
139
|
def is_monitoring_probe(probe: ProbeTarget) -> bool:
|
|
106
140
|
"""True when this endpoint should be used for live service health checks."""
|
|
141
|
+
if _connect_module_api_on_frontend_proxy(probe):
|
|
142
|
+
return False
|
|
143
|
+
if _firmware_plugin_probe_without_runtime(probe):
|
|
144
|
+
return False
|
|
107
145
|
if probe.url.startswith("http"):
|
|
108
146
|
path = urlparse(probe.url).path or probe.url
|
|
109
147
|
else:
|
|
110
148
|
path = probe.url
|
|
149
|
+
path_lower = path.lower()
|
|
150
|
+
if any(path_lower.startswith(prefix) for prefix in _CONNECT_API_PREFIXES):
|
|
151
|
+
return False
|
|
111
152
|
if _HEALTH_HINT.search(path):
|
|
112
153
|
return True
|
|
113
154
|
# Short GET smoke paths (/, /health) without heavy write APIs
|
|
@@ -128,14 +169,38 @@ def _service_path_patterns(services: Sequence[ServiceConfig]) -> Dict[str, List[
|
|
|
128
169
|
|
|
129
170
|
def assign_probe_to_service(probe: ProbeTarget, services: Sequence[ServiceConfig]) -> Optional[str]:
|
|
130
171
|
"""Map a probe URL/path to a configured WUP service name."""
|
|
172
|
+
wup_names = {s.name.lower() for s in services}
|
|
131
173
|
path = urlparse(probe.url).path if probe.url.startswith("http") else probe.url
|
|
132
174
|
path_lower = path.lower()
|
|
133
175
|
|
|
176
|
+
if probe.url.startswith("http"):
|
|
177
|
+
parsed = urlparse(probe.url)
|
|
178
|
+
port = parsed.port
|
|
179
|
+
if port == 8101 and "backend" in wup_names:
|
|
180
|
+
return next(s.name for s in services if s.name.lower() == "backend")
|
|
181
|
+
if port == 8202:
|
|
182
|
+
for svc in services:
|
|
183
|
+
if "firmware" in svc.name.lower():
|
|
184
|
+
return svc.name
|
|
185
|
+
if port == 8100:
|
|
186
|
+
if path_lower.startswith("/firmware"):
|
|
187
|
+
for svc in services:
|
|
188
|
+
if "firmware" in svc.name.lower():
|
|
189
|
+
return svc.name
|
|
190
|
+
if "frontend" in wup_names:
|
|
191
|
+
return next(s.name for s in services if s.name.lower() == "frontend")
|
|
192
|
+
# Connect-* backends on 8103+ — only if a matching WUP service exists
|
|
193
|
+
for svc in services:
|
|
194
|
+
token = svc.name.lower().replace("_", "-")
|
|
195
|
+
if token.startswith("connect-") and token.replace("connect-", "") in path_lower:
|
|
196
|
+
return svc.name
|
|
197
|
+
return None
|
|
198
|
+
|
|
134
199
|
best: Optional[str] = None
|
|
135
200
|
best_len = -1
|
|
136
201
|
for svc in services:
|
|
137
202
|
for token in _service_path_patterns([svc]).get(svc.name, []):
|
|
138
|
-
if len(token) <
|
|
203
|
+
if len(token) < 4 or token in _PATH_TOKEN_BLOCKLIST:
|
|
139
204
|
continue
|
|
140
205
|
if token in path_lower and len(token) > best_len:
|
|
141
206
|
best = svc.name
|
|
@@ -148,7 +213,7 @@ def assign_probe_to_service(probe: ProbeTarget, services: Sequence[ServiceConfig
|
|
|
148
213
|
for svc in services:
|
|
149
214
|
if "firmware" in svc.name.lower():
|
|
150
215
|
return svc.name
|
|
151
|
-
if path_lower.startswith("/api/"):
|
|
216
|
+
if path_lower.startswith("/api/v3"):
|
|
152
217
|
for svc in services:
|
|
153
218
|
if svc.name.lower() in {"backend", "api"}:
|
|
154
219
|
return svc.name
|
|
@@ -193,23 +258,28 @@ class TestQLMonitor:
|
|
|
193
258
|
seen[service].add(key)
|
|
194
259
|
by_service[service].append(probe)
|
|
195
260
|
|
|
196
|
-
# 1) Config-declared endpoints (paths or full URLs)
|
|
197
|
-
base = self._resolve_base_url()
|
|
261
|
+
# 1) Config-declared endpoints (paths or full URLs) — per-service base URL
|
|
198
262
|
for svc_name, paths in (self.config.testql.endpoints_by_service or {}).items():
|
|
263
|
+
base = self._resolve_base_url_for_service(svc_name)
|
|
199
264
|
for path in paths:
|
|
200
265
|
url = self._probeable_url(path, base)
|
|
201
266
|
if not url:
|
|
202
267
|
continue
|
|
203
268
|
probe = ProbeTarget(url=url, source="wup.yaml:endpoints_by_service")
|
|
204
|
-
|
|
269
|
+
if is_monitoring_probe(probe):
|
|
270
|
+
add(svc_name, probe)
|
|
205
271
|
|
|
206
272
|
for path in self.config.testql.explicit_endpoints or []:
|
|
273
|
+
probe = ProbeTarget(url=path, source="wup.yaml:explicit_endpoints")
|
|
274
|
+
assigned = assign_probe_to_service(probe, self.config.services)
|
|
275
|
+
if not assigned:
|
|
276
|
+
continue
|
|
277
|
+
base = self._resolve_base_url_for_service(assigned)
|
|
207
278
|
url = self._probeable_url(path, base)
|
|
208
279
|
if not url:
|
|
209
280
|
continue
|
|
210
281
|
probe = ProbeTarget(url=url, source="wup.yaml:explicit_endpoints")
|
|
211
|
-
|
|
212
|
-
if assigned:
|
|
282
|
+
if is_monitoring_probe(probe):
|
|
213
283
|
add(assigned, probe)
|
|
214
284
|
|
|
215
285
|
if not self.config.testql.endpoint_discovery:
|
|
@@ -235,6 +305,19 @@ class TestQLMonitor:
|
|
|
235
305
|
|
|
236
306
|
return by_service
|
|
237
307
|
|
|
308
|
+
def _resolve_base_url_for_service(self, service: str) -> str:
|
|
309
|
+
tq = self.config.testql
|
|
310
|
+
overrides = getattr(tq, "service_base_urls", None) or {}
|
|
311
|
+
if isinstance(overrides, dict):
|
|
312
|
+
override = (overrides.get(service) or "").strip().rstrip("/")
|
|
313
|
+
if override:
|
|
314
|
+
return override
|
|
315
|
+
if service.lower() in {"backend", "api"}:
|
|
316
|
+
api_base = (getattr(tq, "api_base_url", None) or "").strip().rstrip("/")
|
|
317
|
+
if api_base:
|
|
318
|
+
return api_base
|
|
319
|
+
return self._resolve_base_url()
|
|
320
|
+
|
|
238
321
|
def _probeable_url(self, path: str, base: str) -> Optional[str]:
|
|
239
322
|
if path.startswith("http://") or path.startswith("https://"):
|
|
240
323
|
return path
|
|
@@ -245,7 +328,7 @@ class TestQLMonitor:
|
|
|
245
328
|
def probes_for_service(self, service: str, extra_paths: Iterable[str] = ()) -> List[ProbeTarget]:
|
|
246
329
|
"""Merged probe list for one service (discovery + config + caller extras)."""
|
|
247
330
|
discovered = self.discover_probes_by_service().get(service, [])
|
|
248
|
-
base = self.
|
|
331
|
+
base = self._resolve_base_url_for_service(service)
|
|
249
332
|
merged: List[ProbeTarget] = list(discovered)
|
|
250
333
|
keys = {f"{p.method}:{p.url}" for p in merged}
|
|
251
334
|
|
|
@@ -253,24 +336,31 @@ class TestQLMonitor:
|
|
|
253
336
|
url = self._probeable_url(path, base)
|
|
254
337
|
if not url:
|
|
255
338
|
continue
|
|
256
|
-
|
|
257
|
-
if
|
|
258
|
-
continue
|
|
259
|
-
keys.add(key)
|
|
260
|
-
merged.append(ProbeTarget(url=url, source="runtime"))
|
|
261
|
-
|
|
262
|
-
for path in self.config.testql.endpoints_by_service.get(service, []):
|
|
263
|
-
url = self._probeable_url(path, base)
|
|
264
|
-
if not url:
|
|
339
|
+
probe = ProbeTarget(url=url, source="runtime")
|
|
340
|
+
if not is_monitoring_probe(probe):
|
|
265
341
|
continue
|
|
266
|
-
key = f"
|
|
342
|
+
key = f"{probe.method}:{probe.url}"
|
|
267
343
|
if key in keys:
|
|
268
344
|
continue
|
|
269
345
|
keys.add(key)
|
|
270
|
-
merged.append(
|
|
346
|
+
merged.append(probe)
|
|
271
347
|
|
|
272
348
|
return [p for p in merged if p.url.startswith("http://") or p.url.startswith("https://")]
|
|
273
349
|
|
|
350
|
+
@staticmethod
|
|
351
|
+
def _sort_probes_for_live(probes: Sequence[ProbeTarget]) -> List[ProbeTarget]:
|
|
352
|
+
"""Prefer wup.yaml endpoints before scenario discovery for pass/fail."""
|
|
353
|
+
|
|
354
|
+
def rank(probe: ProbeTarget) -> Tuple[int, str]:
|
|
355
|
+
source = probe.source or ""
|
|
356
|
+
if source.startswith("wup.yaml:endpoints_by_service"):
|
|
357
|
+
return (0, probe.url)
|
|
358
|
+
if source.startswith("wup.yaml:explicit_endpoints"):
|
|
359
|
+
return (1, probe.url)
|
|
360
|
+
return (2, probe.url)
|
|
361
|
+
|
|
362
|
+
return sorted(probes, key=rank)
|
|
363
|
+
|
|
274
364
|
def run_probes(
|
|
275
365
|
self,
|
|
276
366
|
service: str,
|
|
@@ -284,7 +374,8 @@ class TestQLMonitor:
|
|
|
284
374
|
return True, ""
|
|
285
375
|
|
|
286
376
|
failed: List[str] = []
|
|
287
|
-
|
|
377
|
+
ordered = self._sort_probes_for_live(probes)
|
|
378
|
+
for probe in ordered[:max_count]:
|
|
288
379
|
ok, detail = probe.probe(timeout_s=timeout_s)
|
|
289
380
|
if ok:
|
|
290
381
|
continue
|
|
@@ -101,6 +101,27 @@ class TestQLWatcher(WupWatcher):
|
|
|
101
101
|
self.visual_differ = VisualDiffer(project_root, config.visual_diff) if config and config.visual_diff else None
|
|
102
102
|
self.web_client = WebClient(config.web) if config and getattr(config, "web", None) else WebClient()
|
|
103
103
|
self._probe_thread = None
|
|
104
|
+
self._normalize_fleet_health_entry()
|
|
105
|
+
|
|
106
|
+
def _normalize_fleet_health_entry(self) -> None:
|
|
107
|
+
"""Upgrade stale fleet ``down`` to ``degraded`` when health_scenario is non-strict."""
|
|
108
|
+
if not self.config:
|
|
109
|
+
return
|
|
110
|
+
strict = bool(getattr(self.config.testql, "health_scenario_strict", False))
|
|
111
|
+
if strict:
|
|
112
|
+
return
|
|
113
|
+
fleet = self.config.project.name
|
|
114
|
+
entry = self.service_health.get(fleet)
|
|
115
|
+
if not isinstance(entry, dict):
|
|
116
|
+
return
|
|
117
|
+
if entry.get("stage") != "health_scenario":
|
|
118
|
+
return
|
|
119
|
+
if str(entry.get("status", "")).lower() != "down":
|
|
120
|
+
return
|
|
121
|
+
entry = dict(entry)
|
|
122
|
+
entry["status"] = "degraded"
|
|
123
|
+
self.service_health[fleet] = entry
|
|
124
|
+
self._save_service_health()
|
|
104
125
|
|
|
105
126
|
def _load_service_health(self) -> Dict[str, Dict]:
|
|
106
127
|
if not self.health_state_path.exists():
|
|
@@ -188,17 +209,51 @@ class TestQLWatcher(WupWatcher):
|
|
|
188
209
|
return [token for token in raw_tokens if len(token) >= 3]
|
|
189
210
|
|
|
190
211
|
def _get_config_endpoints_for_service(self, service: str) -> List[str]:
|
|
212
|
+
"""Endpoints for *service* only — never attach all explicit_endpoints to every service."""
|
|
213
|
+
from .testql_monitor import ProbeTarget, assign_probe_to_service
|
|
214
|
+
|
|
191
215
|
by_service = self.config.testql.endpoints_by_service or {}
|
|
192
216
|
explicit = self.config.testql.explicit_endpoints or []
|
|
193
217
|
|
|
194
|
-
service_specific = by_service.get(service, [])
|
|
195
218
|
merged: List[str] = []
|
|
196
|
-
for endpoint in
|
|
197
|
-
endpoint_url = self.
|
|
198
|
-
if endpoint_url not in merged:
|
|
219
|
+
for endpoint in by_service.get(service, []):
|
|
220
|
+
endpoint_url = self._to_full_url_for_service(service, endpoint)
|
|
221
|
+
if endpoint_url and endpoint_url not in merged:
|
|
199
222
|
merged.append(endpoint_url)
|
|
223
|
+
|
|
224
|
+
for endpoint in explicit:
|
|
225
|
+
probe = ProbeTarget(
|
|
226
|
+
url=self._to_full_url_for_service(service, endpoint) or endpoint,
|
|
227
|
+
source="wup.yaml:explicit_endpoints",
|
|
228
|
+
)
|
|
229
|
+
if assign_probe_to_service(probe, self.config.services) == service:
|
|
230
|
+
if probe.url not in merged:
|
|
231
|
+
merged.append(probe.url)
|
|
200
232
|
return merged
|
|
201
233
|
|
|
234
|
+
def _to_full_url_for_service(self, service: str, endpoint: str) -> str:
|
|
235
|
+
if endpoint.startswith("http://") or endpoint.startswith("https://"):
|
|
236
|
+
return endpoint
|
|
237
|
+
base = self._resolve_base_url_for_service(service)
|
|
238
|
+
if not base:
|
|
239
|
+
return endpoint
|
|
240
|
+
if endpoint.startswith("/"):
|
|
241
|
+
return f"{base}{endpoint}"
|
|
242
|
+
return f"{base}/{endpoint}"
|
|
243
|
+
|
|
244
|
+
def _resolve_base_url_for_service(self, service: str) -> str:
|
|
245
|
+
"""Per-service base URL (e.g. backend API on :8101, frontend proxy on :8100)."""
|
|
246
|
+
overrides = getattr(self.config.testql, "service_base_urls", None) or {}
|
|
247
|
+
if isinstance(overrides, dict):
|
|
248
|
+
override = (overrides.get(service) or "").strip().rstrip("/")
|
|
249
|
+
if override:
|
|
250
|
+
return override
|
|
251
|
+
if service.lower() in {"backend", "api"}:
|
|
252
|
+
api_base = (getattr(self.config.testql, "api_base_url", None) or "").strip().rstrip("/")
|
|
253
|
+
if api_base:
|
|
254
|
+
return api_base
|
|
255
|
+
return self._resolve_base_url()
|
|
256
|
+
|
|
202
257
|
def _resolve_base_url(self) -> str:
|
|
203
258
|
base_url = (self.config.testql.base_url or "").strip()
|
|
204
259
|
if base_url:
|
|
@@ -253,6 +308,8 @@ class TestQLWatcher(WupWatcher):
|
|
|
253
308
|
score += 2
|
|
254
309
|
if "infra" in name or "smoke" in name:
|
|
255
310
|
score += 1
|
|
311
|
+
if "generated-api-smoke" in name:
|
|
312
|
+
score -= 5
|
|
256
313
|
return score
|
|
257
314
|
|
|
258
315
|
def _select_scenarios_for_service(self, service: str) -> List[Path]:
|
|
@@ -260,20 +317,28 @@ class TestQLWatcher(WupWatcher):
|
|
|
260
317
|
if not all_scenarios:
|
|
261
318
|
return []
|
|
262
319
|
|
|
320
|
+
svc_config = self.get_service_config(service)
|
|
321
|
+
limit = (svc_config.quick_tests.max_endpoints
|
|
322
|
+
if svc_config and svc_config.quick_tests else self.quick_limit)
|
|
323
|
+
|
|
263
324
|
tokens = self._tokenize_service(service)
|
|
264
325
|
scored = sorted(
|
|
265
326
|
((self._score_scenario(s, tokens), s) for s in all_scenarios),
|
|
266
327
|
key=lambda item: (item[0], item[1].name),
|
|
267
328
|
reverse=True,
|
|
268
329
|
)
|
|
269
|
-
selected = [s for score, s in scored if score > 0]
|
|
330
|
+
selected = [s for score, s in scored if score > 0][:limit]
|
|
270
331
|
if selected:
|
|
271
332
|
return selected
|
|
272
333
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
334
|
+
smoke_name = (self.config.testql.smoke_scenario or "").strip()
|
|
335
|
+
if smoke_name:
|
|
336
|
+
for base in (self.scenarios_dir, self.project_root):
|
|
337
|
+
candidate = base / smoke_name
|
|
338
|
+
if candidate.exists():
|
|
339
|
+
return [candidate]
|
|
340
|
+
|
|
341
|
+
return []
|
|
277
342
|
|
|
278
343
|
def _run_testql(self, args: Sequence[str], timeout: int) -> subprocess.CompletedProcess:
|
|
279
344
|
cmd = [self.testql_bin, *args]
|
|
@@ -396,7 +461,8 @@ class TestQLWatcher(WupWatcher):
|
|
|
396
461
|
if not self.monitor:
|
|
397
462
|
return True
|
|
398
463
|
|
|
399
|
-
probes
|
|
464
|
+
# Health probes come only from wup.yaml + TestQL discovery — not deps.json page routes.
|
|
465
|
+
probes = self.monitor.probes_for_service(service)
|
|
400
466
|
if not probes:
|
|
401
467
|
return True
|
|
402
468
|
|
|
@@ -427,6 +493,36 @@ class TestQLWatcher(WupWatcher):
|
|
|
427
493
|
)
|
|
428
494
|
return False
|
|
429
495
|
|
|
496
|
+
@staticmethod
|
|
497
|
+
def _summarize_health_scenario_failure(result: subprocess.CompletedProcess) -> str:
|
|
498
|
+
"""Extract a short human summary from TestQL --output json (avoid trailing '}')."""
|
|
499
|
+
blob = "\n".join(part for part in (result.stdout or "", result.stderr or "") if part).strip()
|
|
500
|
+
if not blob:
|
|
501
|
+
return "health_scenario failed"
|
|
502
|
+
|
|
503
|
+
start = blob.rfind("{")
|
|
504
|
+
if start >= 0:
|
|
505
|
+
try:
|
|
506
|
+
data = json.loads(blob[start:])
|
|
507
|
+
except json.JSONDecodeError:
|
|
508
|
+
data = None
|
|
509
|
+
if isinstance(data, dict):
|
|
510
|
+
passed = data.get("passed")
|
|
511
|
+
failed = data.get("failed")
|
|
512
|
+
if isinstance(passed, int) and isinstance(failed, int):
|
|
513
|
+
total = passed + failed
|
|
514
|
+
errors = data.get("errors") or []
|
|
515
|
+
hint = f" — {errors[0]}" if errors else ""
|
|
516
|
+
return f"{passed}/{total} passed, {failed} failed{hint}"
|
|
517
|
+
|
|
518
|
+
for line in reversed(blob.splitlines()):
|
|
519
|
+
stripped = line.strip()
|
|
520
|
+
if not stripped or stripped in {"}", "{"}:
|
|
521
|
+
continue
|
|
522
|
+
if "passed" in stripped.lower() or "failed" in stripped.lower() or "❌" in stripped:
|
|
523
|
+
return stripped
|
|
524
|
+
return "health_scenario failed"
|
|
525
|
+
|
|
430
526
|
async def _run_fleet_health_scenario(self) -> bool:
|
|
431
527
|
"""Optional full TestQL run (not dry-run) for fleet-wide health scenarios."""
|
|
432
528
|
scenario_name = (self.config.testql.health_scenario or "").strip()
|
|
@@ -435,9 +531,12 @@ class TestQLWatcher(WupWatcher):
|
|
|
435
531
|
|
|
436
532
|
scenario_path = Path(scenario_name)
|
|
437
533
|
if not scenario_path.is_absolute():
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
534
|
+
candidates = [
|
|
535
|
+
self.scenarios_dir / scenario_name,
|
|
536
|
+
self.project_root / scenario_name,
|
|
537
|
+
self.project_root / self.config.testql.scenario_dir / scenario_name,
|
|
538
|
+
]
|
|
539
|
+
scenario_path = next((p for p in candidates if p.exists()), candidates[0])
|
|
441
540
|
if not scenario_path.exists():
|
|
442
541
|
self.console.print(f"[yellow]⚠ health_scenario not found: {scenario_name}[/yellow]")
|
|
443
542
|
return True
|
|
@@ -454,17 +553,26 @@ class TestQLWatcher(WupWatcher):
|
|
|
454
553
|
)
|
|
455
554
|
return True
|
|
456
555
|
|
|
457
|
-
|
|
556
|
+
summary = self._summarize_health_scenario_failure(result)
|
|
458
557
|
track_path = self._write_track(service=fleet, stage="health_scenario", scenario=scenario_path, result=result)
|
|
558
|
+
strict = bool(getattr(self.config.testql, "health_scenario_strict", False))
|
|
559
|
+
# Non-strict: informational only — must not mark fleet "down" (koru reads service-health.json).
|
|
560
|
+
fleet_status = "down" if strict else "degraded"
|
|
459
561
|
self._record_health_transition(
|
|
460
562
|
service=fleet,
|
|
461
|
-
status=
|
|
563
|
+
status=fleet_status,
|
|
462
564
|
stage="health_scenario",
|
|
463
|
-
message=
|
|
565
|
+
message=summary[:500],
|
|
464
566
|
track_file=str(track_path),
|
|
465
567
|
)
|
|
466
|
-
|
|
467
|
-
|
|
568
|
+
if strict:
|
|
569
|
+
self.console.print(f"[red]✗ Fleet health scenario failed: {summary}[/red]")
|
|
570
|
+
return False
|
|
571
|
+
self.console.print(
|
|
572
|
+
f"[yellow]⚠ Fleet health scenario incomplete: {summary} "
|
|
573
|
+
f"(per-service probes continue; set testql.health_scenario_strict: true to hard-fail)[/yellow]"
|
|
574
|
+
)
|
|
575
|
+
return True
|
|
468
576
|
|
|
469
577
|
async def run_quick_test(self, service: str, endpoints: List[str]) -> bool:
|
|
470
578
|
merged_endpoints = self._merge_endpoints(service, endpoints)
|
|
@@ -35,6 +35,7 @@ console = Console()
|
|
|
35
35
|
# ---------------------------------------------------------------------------
|
|
36
36
|
|
|
37
37
|
_PW_AVAILABLE: Optional[bool] = None
|
|
38
|
+
_PW_WARNED: bool = False
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
def _playwright_available() -> bool:
|
|
@@ -48,6 +49,17 @@ def _playwright_available() -> bool:
|
|
|
48
49
|
return _PW_AVAILABLE
|
|
49
50
|
|
|
50
51
|
|
|
52
|
+
def _warn_playwright_missing() -> None:
|
|
53
|
+
global _PW_WARNED
|
|
54
|
+
if _PW_WARNED:
|
|
55
|
+
return
|
|
56
|
+
_PW_WARNED = True
|
|
57
|
+
console.print(
|
|
58
|
+
"[yellow]visual_diff: playwright not installed — DOM scan skipped "
|
|
59
|
+
"(pip install playwright && playwright install chromium)[/yellow]"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
51
63
|
_DOM_SNAPSHOT_JS = """
|
|
52
64
|
(maxDepth) => {
|
|
53
65
|
function snapshot(node, depth) {
|
|
@@ -85,7 +97,7 @@ async def _fetch_dom_snapshot(
|
|
|
85
97
|
) -> Optional[Dict]:
|
|
86
98
|
"""Return a DOM structure dict for *url* using Playwright."""
|
|
87
99
|
if not _playwright_available():
|
|
88
|
-
|
|
100
|
+
_warn_playwright_missing()
|
|
89
101
|
return None
|
|
90
102
|
try:
|
|
91
103
|
from playwright.async_api import async_playwright
|
|
@@ -273,7 +285,8 @@ class VisualDiffer:
|
|
|
273
285
|
pages: List[str] = list(self.cfg.pages)
|
|
274
286
|
|
|
275
287
|
if self.cfg.pages_from_endpoints and endpoints:
|
|
276
|
-
|
|
288
|
+
for endpoint in endpoints:
|
|
289
|
+
pages.append(endpoint)
|
|
277
290
|
|
|
278
291
|
if not pages:
|
|
279
292
|
pages = [f"/{service}"]
|
|
@@ -297,10 +310,17 @@ class VisualDiffer:
|
|
|
297
310
|
if not self.cfg.enabled:
|
|
298
311
|
return []
|
|
299
312
|
|
|
313
|
+
if not _playwright_available():
|
|
314
|
+
_warn_playwright_missing()
|
|
315
|
+
return []
|
|
316
|
+
|
|
300
317
|
if self.cfg.delay_seconds > 0:
|
|
301
318
|
await asyncio.sleep(self.cfg.delay_seconds)
|
|
302
319
|
|
|
303
320
|
pages = self._pages_for_service(service, endpoints)
|
|
321
|
+
max_pages = max(1, int(self.cfg.max_pages or 5))
|
|
322
|
+
if len(pages) > max_pages:
|
|
323
|
+
pages = pages[:max_pages]
|
|
304
324
|
results = []
|
|
305
325
|
for url in pages:
|
|
306
326
|
result = await self._check_page(service, url)
|
|
@@ -321,7 +341,10 @@ class VisualDiffer:
|
|
|
321
341
|
)
|
|
322
342
|
elif result["diff"]["status"] == "new":
|
|
323
343
|
console.print(f"[dim]📷 Baseline snapshot: {url}[/dim]")
|
|
324
|
-
|
|
344
|
+
elif result["diff"]["status"] == "error":
|
|
345
|
+
message = result["diff"].get("message", "scan failed")
|
|
346
|
+
console.print(f"[yellow]⚠ Visual diff skipped: {url} ({message})[/yellow]")
|
|
347
|
+
elif result["diff"]["status"] == "ok":
|
|
325
348
|
console.print(f"[dim green]✓ No DOM change: {url}[/dim green]")
|
|
326
349
|
|
|
327
350
|
return results
|
|
@@ -26,6 +26,7 @@ from .models.config import WebConfig
|
|
|
26
26
|
_console = Console()
|
|
27
27
|
_HTTPX_AVAILABLE: Optional[bool] = None
|
|
28
28
|
_HTTPX_WARN_LOGGED: bool = False
|
|
29
|
+
_SEND_FAIL_WARNED: bool = False
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
def _httpx_available() -> bool:
|
|
@@ -122,7 +123,13 @@ class WebClient:
|
|
|
122
123
|
)
|
|
123
124
|
return False
|
|
124
125
|
except Exception as exc: # noqa: BLE001 — soft-fail by design
|
|
125
|
-
|
|
126
|
+
global _SEND_FAIL_WARNED
|
|
127
|
+
if not _SEND_FAIL_WARNED:
|
|
128
|
+
_SEND_FAIL_WARNED = True
|
|
129
|
+
_console.print(
|
|
130
|
+
f"[yellow]wup.web_client: send_event failed ({exc}) — "
|
|
131
|
+
f"set web.enabled=false or start wupbro at {self.endpoint}[/yellow]"
|
|
132
|
+
)
|
|
126
133
|
return False
|
|
127
134
|
|
|
128
135
|
async def send_regression(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wup
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.28
|
|
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-Expression: 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:** $
|
|
36
|
-
- 👤 **Human dev:** ~$
|
|
35
|
+
- 🤖 **LLM usage:** $2.2653 (38 commits)
|
|
36
|
+
- 👤 **Human dev:** ~$1632 (16.3h @ $100/h, 30min dedup)
|
|
37
37
|
|
|
38
|
-
Generated on 2026-05-
|
|
38
|
+
Generated on 2026-05-17 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
|
|
|
@@ -83,7 +83,7 @@ wup assistant --quick --template fastapi
|
|
|
83
83
|
# 3. Build dependency map (one-time setup)
|
|
84
84
|
wup map-deps ./my-project
|
|
85
85
|
|
|
86
|
-
# 4. Start watching
|
|
86
|
+
# 4. Start watching (TestQL + live probes every 60s by default)
|
|
87
87
|
wup watch ./my-project
|
|
88
88
|
|
|
89
89
|
# 5. Start with live dashboard
|
|
@@ -110,14 +110,18 @@ wup map-deps ./my-project --output my-deps.json
|
|
|
110
110
|
### Watch Project
|
|
111
111
|
|
|
112
112
|
```bash
|
|
113
|
-
# Basic watching (uses wup.yaml if present)
|
|
113
|
+
# Basic watching: TestQL mode + live HTTP probes every 60s (uses wup.yaml if present)
|
|
114
114
|
wup watch ./my-project
|
|
115
115
|
|
|
116
|
+
# Legacy HTTP-only watcher (no TestQL, no periodic probes unless configured)
|
|
117
|
+
wup watch ./my-project --mode default --probe-interval 0
|
|
118
|
+
|
|
116
119
|
# With custom settings
|
|
117
120
|
wup watch ./my-project \
|
|
118
121
|
--cpu-throttle 0.5 \
|
|
119
122
|
--debounce 3 \
|
|
120
|
-
--cooldown 600
|
|
123
|
+
--cooldown 600 \
|
|
124
|
+
--probe-interval 120
|
|
121
125
|
|
|
122
126
|
# With live dashboard
|
|
123
127
|
wup watch ./my-project --dashboard
|
|
@@ -125,9 +129,6 @@ wup watch ./my-project --dashboard
|
|
|
125
129
|
# Use specific config file
|
|
126
130
|
wup watch ./my-project --config custom-config.yaml
|
|
127
131
|
|
|
128
|
-
# TestQL mode
|
|
129
|
-
wup watch ./my-project --mode testql
|
|
130
|
-
|
|
131
132
|
# Discover endpoints from TestQL scenarios
|
|
132
133
|
wup testql-endpoints /path/to/scenarios --output testql-deps.json
|
|
133
134
|
```
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|