wup 0.2.63__tar.gz → 0.2.65__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.63/wup.egg-info → wup-0.2.65}/PKG-INFO +7 -7
- {wup-0.2.63 → wup-0.2.65}/README.md +6 -6
- {wup-0.2.63 → wup-0.2.65}/pyproject.toml +1 -1
- {wup-0.2.63 → wup-0.2.65}/tests/test_testql_monitor.py +53 -6
- {wup-0.2.63 → wup-0.2.65}/tests/test_wup.py +31 -0
- {wup-0.2.63 → wup-0.2.65}/wup/__init__.py +1 -1
- {wup-0.2.63 → wup-0.2.65}/wup/config.py +11 -5
- {wup-0.2.63 → wup-0.2.65}/wup/models/config.py +3 -0
- {wup-0.2.63 → wup-0.2.65}/wup/monitoring_manifest.py +30 -5
- {wup-0.2.63 → wup-0.2.65}/wup/testing/handlers/health_handlers.py +11 -7
- {wup-0.2.63 → wup-0.2.65}/wup/testql_monitor.py +197 -32
- {wup-0.2.63 → wup-0.2.65}/wup/testql_watcher.py +51 -2
- {wup-0.2.63 → wup-0.2.65/wup.egg-info}/PKG-INFO +7 -7
- {wup-0.2.63 → wup-0.2.65}/LICENSE +0 -0
- {wup-0.2.63 → wup-0.2.65}/setup.cfg +0 -0
- {wup-0.2.63 → wup-0.2.65}/tests/test_assistant.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/tests/test_auto_detection.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/tests/test_cli_filtering.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/tests/test_e2e.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/tests/test_health_summary_passed.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/tests/test_monitoring_manifest.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/tests/test_probe_mutex.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/tests/test_service_inference.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/tests/test_testql_watcher.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/tests/test_visual_diff_periodic_skip.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/tests/test_visual_diff_progress.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/tests/test_watch_exclude.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/tests/test_web_client.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/_ast_detector.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/_base_detector.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/_hash_detector.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/_yaml_detector.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/anomaly_detector.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/anomaly_models.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/assistant.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/assistant_discovery.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/assistant_validator.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/bus.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/cli.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/cli_config_generator.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/cli_scanner.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/core.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/dependency_mapper.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/event_store.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/file_watcher/events/file_events.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/models/__init__.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/planfile_reporter.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/testing/events/health_events.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/testing/events/test_results.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/testing/handlers/event_handlers.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/testing/queries/health_queries.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/testql_cli_generator.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/testql_discovery.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/visual_diff.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup/web_client.py +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup.egg-info/SOURCES.txt +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.63 → wup-0.2.65}/wup.egg-info/requires.txt +0 -0
- {wup-0.2.63 → wup-0.2.65}/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.65
|
|
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
|
|
@@ -31,17 +31,17 @@ Dynamic: license-file
|
|
|
31
31
|
|
|
32
32
|
## AI Cost Tracking
|
|
33
33
|
|
|
34
|
-
     
|
|
35
|
+
  
|
|
36
36
|
|
|
37
|
-
- 🤖 **LLM usage:** $3.
|
|
38
|
-
- 👤 **Human dev:** ~$
|
|
37
|
+
- 🤖 **LLM usage:** $3.4955 (77 commits)
|
|
38
|
+
- 👤 **Human dev:** ~$3318 (33.2h @ $100/h, 30min dedup)
|
|
39
39
|
|
|
40
|
-
Generated on 2026-
|
|
40
|
+
Generated on 2026-06-03 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
41
41
|
|
|
42
42
|
---
|
|
43
43
|
|
|
44
|
-
    
|
|
45
45
|
|
|
46
46
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
47
47
|
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
     
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $3.
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $3.4955 (77 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$3318 (33.2h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
|
-
Generated on 2026-
|
|
12
|
+
Generated on 2026-06-03 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
|
|
|
@@ -34,11 +34,48 @@ API[1]{method, endpoint, expected_status}:
|
|
|
34
34
|
assert is_monitoring_probe(probes[0])
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
def
|
|
37
|
+
def test_hardware_identify_and_peripheral_status_are_live_probes():
|
|
38
|
+
assert is_monitoring_probe(
|
|
39
|
+
ProbeTarget(url="http://localhost:8202/api/v1/hardware/identify")
|
|
40
|
+
)
|
|
41
|
+
assert is_monitoring_probe(
|
|
42
|
+
ProbeTarget(url="http://localhost:8096/api/v3/hardware/peripheral-status/modbus-io")
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_firmware_plugin_health_catalog_not_periodic_live_probe():
|
|
47
|
+
"""Plugin health is listed for detail TestQL; live watch uses identify + peripheral-status."""
|
|
38
48
|
probe = ProbeTarget(url="http://localhost:8202/api/v1/plugins/modbus-io/health")
|
|
39
|
-
assert
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
assert is_monitoring_probe(probe)
|
|
50
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
51
|
+
root = Path(tmpdir)
|
|
52
|
+
cfg = WupConfig(
|
|
53
|
+
project=ProjectConfig(name="demo"),
|
|
54
|
+
services=[
|
|
55
|
+
ServiceConfig(name="firmware", paths=["backend/firmware/**"]),
|
|
56
|
+
ServiceConfig(name="connect-scenario", paths=["connect-scenario/**"]),
|
|
57
|
+
],
|
|
58
|
+
watch=WatchConfig(),
|
|
59
|
+
testql=TestQLConfig(
|
|
60
|
+
hardware_usb_modules={
|
|
61
|
+
"oqlos_url": "http://localhost:8202",
|
|
62
|
+
"proxy_url": "http://localhost:8096",
|
|
63
|
+
"module_ids": ["modbus-io", "modbus-adc"],
|
|
64
|
+
},
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
monitor = TestQLMonitor(root, cfg)
|
|
68
|
+
firmware = {p.url for p in monitor.probes_for_service("firmware")}
|
|
69
|
+
scenario = {p.url for p in monitor.probes_for_service("connect-scenario")}
|
|
70
|
+
assert "http://localhost:8202/api/v1/hardware/identify" in firmware
|
|
71
|
+
assert "http://localhost:8202/api/v1/plugins/modbus-io/health" not in firmware
|
|
72
|
+
assert "http://localhost:8096/api/v3/hardware/identify" in scenario
|
|
73
|
+
assert (
|
|
74
|
+
"http://localhost:8096/api/v3/hardware/peripheral-status/modbus-io" in scenario
|
|
75
|
+
)
|
|
76
|
+
assert (
|
|
77
|
+
"http://localhost:8096/api/v3/hardware/peripheral-status/modbus-adc" in scenario
|
|
78
|
+
)
|
|
42
79
|
|
|
43
80
|
|
|
44
81
|
def test_connect_api_paths_on_8100_are_not_monitoring_probes():
|
|
@@ -108,8 +145,18 @@ def test_monitor_merges_config_and_service_map():
|
|
|
108
145
|
probes = monitor.probes_for_service("firmware")
|
|
109
146
|
urls = {p.url for p in probes}
|
|
110
147
|
assert "http://localhost:8100/firmware/api/v1/health" in urls
|
|
111
|
-
|
|
112
|
-
assert "http://localhost:8100/firmware/api/v1/execution/
|
|
148
|
+
# Execution telemetry is for fleet TestQL, not WUP live liveness probes.
|
|
149
|
+
assert "http://localhost:8100/firmware/api/v1/execution/status" not in urls
|
|
150
|
+
assert "http://localhost:8100/firmware/api/v1/execution/logs" not in urls
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_firmware_live_probe_prefers_oqlos_8202():
|
|
154
|
+
probes = [
|
|
155
|
+
ProbeTarget(url="http://localhost:8100/firmware/api/v1/health"),
|
|
156
|
+
ProbeTarget(url="http://localhost:8202/health"),
|
|
157
|
+
]
|
|
158
|
+
ordered = TestQLMonitor._sort_probes_for_live(probes, service="firmware")
|
|
159
|
+
assert ordered[0].url == "http://localhost:8202/health"
|
|
113
160
|
|
|
114
161
|
|
|
115
162
|
def test_probes_for_service_ignores_non_health_extra_paths():
|
|
@@ -1790,6 +1790,37 @@ class TestTestQLWatcherConfig:
|
|
|
1790
1790
|
scenarios = watcher._select_scenarios_for_service("users")
|
|
1791
1791
|
# Should be limited by config when no matching scenarios found
|
|
1792
1792
|
assert len(scenarios) <= 2
|
|
1793
|
+
|
|
1794
|
+
def test_testql_watcher_select_scenarios_uses_pinned_scenario(self):
|
|
1795
|
+
"""Pinned quick_tests.scenario wins over auto-api scoring."""
|
|
1796
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
1797
|
+
scenarios_dir = Path(tmpdir) / "testql-scenarios"
|
|
1798
|
+
scenarios_dir.mkdir()
|
|
1799
|
+
(scenarios_dir / "auto-api-connect-workshop.testql.toon.yaml").write_text("# workshop")
|
|
1800
|
+
pinned = scenarios_dir / "connect-scenario-wup-quick-fast.testql.toon.yaml"
|
|
1801
|
+
pinned.write_text("# sentinel")
|
|
1802
|
+
|
|
1803
|
+
service = ServiceConfig(
|
|
1804
|
+
name="connect-scenario",
|
|
1805
|
+
type="web",
|
|
1806
|
+
paths=["connect-scenario/**"],
|
|
1807
|
+
quick_tests=ServiceTestConfig(
|
|
1808
|
+
scope="all",
|
|
1809
|
+
max_endpoints=1,
|
|
1810
|
+
scenario="connect-scenario-wup-quick-fast.testql.toon.yaml",
|
|
1811
|
+
),
|
|
1812
|
+
)
|
|
1813
|
+
config = WupConfig(
|
|
1814
|
+
project=ProjectConfig(name="test"),
|
|
1815
|
+
watch=WatchConfig(),
|
|
1816
|
+
services=[service],
|
|
1817
|
+
test_strategy=TestStrategyConfig(),
|
|
1818
|
+
testql=TestQLConfig(),
|
|
1819
|
+
)
|
|
1820
|
+
|
|
1821
|
+
watcher = TestQLWatcher(tmpdir, scenarios_dir="testql-scenarios", config=config)
|
|
1822
|
+
selected = watcher._select_scenarios_for_service("connect-scenario")
|
|
1823
|
+
assert selected == [pinned]
|
|
1793
1824
|
|
|
1794
1825
|
def test_testql_watcher_uses_config_timeout(self):
|
|
1795
1826
|
"""Test that TestQLWatcher uses config timeout settings."""
|
|
@@ -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.65"
|
|
11
11
|
__author__ = "Tom Sapletta"
|
|
12
12
|
|
|
13
13
|
from .config import load_config, save_config, get_default_config
|
|
@@ -139,11 +139,13 @@ def _parse_services_config(raw: dict) -> List[ServiceConfig]:
|
|
|
139
139
|
type=svc_raw.get("type", "auto"),
|
|
140
140
|
quick_tests=ServiceTestConfig(
|
|
141
141
|
scope=quick_tests_raw.get("scope", "all"),
|
|
142
|
-
max_endpoints=quick_tests_raw.get("max_endpoints", 10)
|
|
142
|
+
max_endpoints=quick_tests_raw.get("max_endpoints", 10),
|
|
143
|
+
scenario=quick_tests_raw.get("scenario", ""),
|
|
143
144
|
),
|
|
144
145
|
detail_tests=ServiceTestConfig(
|
|
145
146
|
scope=detail_tests_raw.get("scope", "all"),
|
|
146
|
-
max_endpoints=detail_tests_raw.get("max_endpoints", 10)
|
|
147
|
+
max_endpoints=detail_tests_raw.get("max_endpoints", 10),
|
|
148
|
+
scenario=detail_tests_raw.get("scenario", ""),
|
|
147
149
|
),
|
|
148
150
|
cpu_throttle=svc_raw.get("cpu_throttle", 0.8),
|
|
149
151
|
notify=NotifyConfig(
|
|
@@ -233,7 +235,8 @@ def _parse_testql_config(raw: dict) -> TestQLConfig:
|
|
|
233
235
|
base_url_env=testql_raw.get("base_url_env", "WUP_BASE_URL"),
|
|
234
236
|
service_base_urls=testql_raw.get("service_base_urls", {}),
|
|
235
237
|
explicit_endpoints=testql_raw.get("explicit_endpoints", []),
|
|
236
|
-
endpoints_by_service=testql_raw.get("endpoints_by_service", {})
|
|
238
|
+
endpoints_by_service=testql_raw.get("endpoints_by_service", {}),
|
|
239
|
+
hardware_usb_modules=testql_raw.get("hardware_usb_modules", {}),
|
|
237
240
|
)
|
|
238
241
|
|
|
239
242
|
|
|
@@ -473,6 +476,7 @@ def save_config(config: WupConfig, output_path: Path):
|
|
|
473
476
|
"service_base_urls": config.testql.service_base_urls,
|
|
474
477
|
"explicit_endpoints": config.testql.explicit_endpoints,
|
|
475
478
|
"endpoints_by_service": config.testql.endpoints_by_service,
|
|
479
|
+
"hardware_usb_modules": config.testql.hardware_usb_modules,
|
|
476
480
|
},
|
|
477
481
|
"visual_diff": {
|
|
478
482
|
"enabled": config.visual_diff.enabled,
|
|
@@ -531,11 +535,13 @@ def save_config(config: WupConfig, output_path: Path):
|
|
|
531
535
|
"paths": svc.paths,
|
|
532
536
|
"quick_tests": {
|
|
533
537
|
"scope": svc.quick_tests.scope,
|
|
534
|
-
"max_endpoints": svc.quick_tests.max_endpoints
|
|
538
|
+
"max_endpoints": svc.quick_tests.max_endpoints,
|
|
539
|
+
**({"scenario": svc.quick_tests.scenario} if svc.quick_tests.scenario else {}),
|
|
535
540
|
},
|
|
536
541
|
"detail_tests": {
|
|
537
542
|
"scope": svc.detail_tests.scope,
|
|
538
|
-
"max_endpoints": svc.detail_tests.max_endpoints
|
|
543
|
+
"max_endpoints": svc.detail_tests.max_endpoints,
|
|
544
|
+
**({"scenario": svc.detail_tests.scenario} if svc.detail_tests.scenario else {}),
|
|
539
545
|
},
|
|
540
546
|
"cpu_throttle": svc.cpu_throttle,
|
|
541
547
|
"notify": {
|
|
@@ -23,6 +23,7 @@ class ServiceTestConfig:
|
|
|
23
23
|
"""Test configuration for a service (quick or detail)."""
|
|
24
24
|
scope: str = "all" # "read", "write", "auth", "all", or comma-separated
|
|
25
25
|
max_endpoints: int = 10
|
|
26
|
+
scenario: str = "" # Optional pinned scenario path (relative to project root or scenarios_dir)
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
@dataclass
|
|
@@ -74,6 +75,8 @@ class TestQLConfig:
|
|
|
74
75
|
service_base_urls: Dict[str, str] = field(default_factory=dict) # optional per-service override
|
|
75
76
|
explicit_endpoints: List[str] = field(default_factory=list)
|
|
76
77
|
endpoints_by_service: Dict[str, List[str]] = field(default_factory=dict)
|
|
78
|
+
# USB kit modules: expand HTTP API probes from module_ids (no /dev/* paths).
|
|
79
|
+
hardware_usb_modules: Dict[str, object] = field(default_factory=dict)
|
|
77
80
|
|
|
78
81
|
|
|
79
82
|
@dataclass
|
|
@@ -105,15 +105,22 @@ def discover_docker_compose_services(project_root: Path) -> List[DockerComposeSe
|
|
|
105
105
|
return discovered_services
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
_BASH_DEFAULT_RE = re.compile(r"\$\{[^}]*:-(\d+)\}")
|
|
109
|
+
|
|
110
|
+
|
|
108
111
|
def _host_port_from_mapping(mapping: str) -> Optional[int]:
|
|
109
|
-
"""Extract host port from '8100:8100' or '8100:
|
|
112
|
+
"""Extract host port from '8100:8100', '8100:80', or '${VAR:-8100}:8100'."""
|
|
110
113
|
text = mapping.strip().strip("'\"")
|
|
111
114
|
if ":" not in text:
|
|
112
115
|
return None
|
|
113
|
-
|
|
116
|
+
# Split on last colon: ${VAR:-default}:container contains internal colons
|
|
117
|
+
host = text.rsplit(":", 1)[0]
|
|
114
118
|
try:
|
|
115
119
|
return int(host)
|
|
116
120
|
except ValueError:
|
|
121
|
+
m = _BASH_DEFAULT_RE.search(host)
|
|
122
|
+
if m:
|
|
123
|
+
return int(m.group(1))
|
|
117
124
|
return None
|
|
118
125
|
|
|
119
126
|
|
|
@@ -124,16 +131,34 @@ def _map_docker_to_wup_service(
|
|
|
124
131
|
name = docker.compose_service.lower()
|
|
125
132
|
container = docker.container_name.lower()
|
|
126
133
|
|
|
127
|
-
|
|
134
|
+
def _connect_backend_target() -> Optional[str]:
|
|
135
|
+
if not (name.endswith("-backend") and "connect" in name):
|
|
136
|
+
return None
|
|
137
|
+
specific = name.replace("-backend", "")
|
|
138
|
+
if specific in wup_services:
|
|
139
|
+
return specific
|
|
140
|
+
if "backend" in wup_services:
|
|
141
|
+
return "backend"
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
rules: list[tuple[Any, Any]] = [
|
|
128
145
|
(lambda: "firmware" in name or "firmware" in container, "firmware"),
|
|
129
146
|
(lambda: name == "frontend" or "frontend" in container, "frontend"),
|
|
130
147
|
(lambda: name == "backend" or container == "identification-backend", "backend"),
|
|
131
|
-
(lambda:
|
|
148
|
+
(lambda: "connect-scenario" in name and "backend" not in name and "backend" in wup_services, "backend"),
|
|
132
149
|
]
|
|
133
150
|
for predicate, target in rules:
|
|
134
|
-
if
|
|
151
|
+
if callable(target):
|
|
152
|
+
resolved = target()
|
|
153
|
+
if predicate() and resolved and resolved in wup_services:
|
|
154
|
+
return resolved
|
|
155
|
+
elif predicate() and target in wup_services:
|
|
135
156
|
return target
|
|
136
157
|
|
|
158
|
+
connect_target = _connect_backend_target()
|
|
159
|
+
if connect_target:
|
|
160
|
+
return connect_target
|
|
161
|
+
|
|
137
162
|
for svc in wup_services:
|
|
138
163
|
token = svc.lower().replace("_", "-")
|
|
139
164
|
if token in name or token in container:
|
|
@@ -57,13 +57,17 @@ class ServiceHealthProjection:
|
|
|
57
57
|
|
|
58
58
|
# Notify external systems
|
|
59
59
|
if event.status in {"down", "degraded"}:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
60
|
+
# Non-strict fleet health uses status=degraded for optional probe gaps; do not spam planfile.
|
|
61
|
+
if event.stage == "health_scenario" and event.status == "degraded":
|
|
62
|
+
pass
|
|
63
|
+
else:
|
|
64
|
+
self.planfile_reporter.report_failure(
|
|
65
|
+
service=event.service,
|
|
66
|
+
status=event.status,
|
|
67
|
+
stage=event.stage,
|
|
68
|
+
message=event.message,
|
|
69
|
+
track_file=event.track_file,
|
|
70
|
+
)
|
|
67
71
|
elif event.status == "up":
|
|
68
72
|
self.planfile_reporter.clear_service_stage(
|
|
69
73
|
service=event.service, stage=event.stage
|
|
@@ -18,8 +18,12 @@ _API_LINE = re.compile(
|
|
|
18
18
|
r"^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*,\s*([^\s,]+)(?:\s*,\s*(\d+))?",
|
|
19
19
|
re.MULTILINE,
|
|
20
20
|
)
|
|
21
|
+
_SHELL_CURL_URL = re.compile(
|
|
22
|
+
r"""^\s*SHELL\s+["']curl\b[^"']*\s(https?://[^\s"']+)""",
|
|
23
|
+
re.MULTILINE,
|
|
24
|
+
)
|
|
21
25
|
_HEALTH_HINT = re.compile(
|
|
22
|
-
r"(/health|/healthz|/ready|/live|/status|/openapi\.json
|
|
26
|
+
r"(/health|/healthz|/ready|/live|/status|/openapi\.json)",
|
|
23
27
|
re.IGNORECASE,
|
|
24
28
|
)
|
|
25
29
|
# Connect module APIs live on :8103+ — not valid health probes on frontend proxy :8100
|
|
@@ -76,13 +80,21 @@ def _parse_api_lines(content: str, source: str) -> List[ProbeTarget]:
|
|
|
76
80
|
return probes
|
|
77
81
|
|
|
78
82
|
|
|
83
|
+
def _parse_shell_curl_lines(content: str, source: str) -> List[ProbeTarget]:
|
|
84
|
+
probes: List[ProbeTarget] = []
|
|
85
|
+
for url in _SHELL_CURL_URL.findall(content):
|
|
86
|
+
url = url.strip().rstrip('"\'')
|
|
87
|
+
probes.append(ProbeTarget(url=url, method="GET", expected_status=200, source=source))
|
|
88
|
+
return probes
|
|
89
|
+
|
|
90
|
+
|
|
79
91
|
def parse_scenario_probes(scenario_path: Path) -> List[ProbeTarget]:
|
|
80
92
|
"""Extract API probe rows from a TestQL TOON scenario file."""
|
|
81
93
|
try:
|
|
82
94
|
content = scenario_path.read_text(encoding="utf-8")
|
|
83
95
|
except OSError:
|
|
84
96
|
return []
|
|
85
|
-
return _parse_api_lines(content, source=str(scenario_path))
|
|
97
|
+
return _parse_api_lines(content, source=str(scenario_path)) + _parse_shell_curl_lines(content, source=str(scenario_path))
|
|
86
98
|
|
|
87
99
|
|
|
88
100
|
def _extract_base_url(data: Dict[str, Any]) -> str:
|
|
@@ -137,23 +149,10 @@ def _connect_module_api_on_frontend_proxy(probe: ProbeTarget) -> bool:
|
|
|
137
149
|
return any(path.startswith(prefix) for prefix in _CONNECT_API_PREFIXES)
|
|
138
150
|
|
|
139
151
|
|
|
140
|
-
def _firmware_plugin_probe_without_runtime(probe: ProbeTarget) -> bool:
|
|
141
|
-
"""Plugin health on :8202 requires loaded plugins — skip for bare simulator live probes."""
|
|
142
|
-
if not probe.url.startswith("http"):
|
|
143
|
-
return False
|
|
144
|
-
parsed = urlparse(probe.url)
|
|
145
|
-
if parsed.port != 8202:
|
|
146
|
-
return False
|
|
147
|
-
path = (parsed.path or "").lower()
|
|
148
|
-
return "/api/v1/plugins/" in path and path.endswith("/health")
|
|
149
|
-
|
|
150
|
-
|
|
151
152
|
def is_monitoring_probe(probe: ProbeTarget) -> bool:
|
|
152
153
|
"""True when this endpoint should be used for live service health checks."""
|
|
153
154
|
if _connect_module_api_on_frontend_proxy(probe):
|
|
154
155
|
return False
|
|
155
|
-
if _firmware_plugin_probe_without_runtime(probe):
|
|
156
|
-
return False
|
|
157
156
|
if probe.url.startswith("http"):
|
|
158
157
|
path = urlparse(probe.url).path or probe.url
|
|
159
158
|
else:
|
|
@@ -161,6 +160,13 @@ def is_monitoring_probe(probe: ProbeTarget) -> bool:
|
|
|
161
160
|
path_lower = path.lower()
|
|
162
161
|
if any(path_lower.startswith(prefix) for prefix in _CONNECT_API_PREFIXES):
|
|
163
162
|
return False
|
|
163
|
+
if "/execution/" in path_lower:
|
|
164
|
+
return False
|
|
165
|
+
# Hardware USB kit: enumerate adapters via OqlOS/proxy HTTP (not serial port paths).
|
|
166
|
+
if "/hardware/identify" in path_lower:
|
|
167
|
+
return True
|
|
168
|
+
if "/peripheral-status/" in path_lower:
|
|
169
|
+
return True
|
|
164
170
|
if _HEALTH_HINT.search(path):
|
|
165
171
|
return True
|
|
166
172
|
# Short GET smoke paths (/, /health) without heavy write APIs
|
|
@@ -228,7 +234,10 @@ def _assign_by_connect_backend(
|
|
|
228
234
|
|
|
229
235
|
|
|
230
236
|
def _assign_http_probe(
|
|
231
|
-
probe: ProbeTarget,
|
|
237
|
+
probe: ProbeTarget,
|
|
238
|
+
services: Sequence[ServiceConfig],
|
|
239
|
+
path_lower: str,
|
|
240
|
+
port_map: Optional[Dict[int, str]] = None,
|
|
232
241
|
) -> Optional[str]:
|
|
233
242
|
"""Map an HTTP probe to a service based on port and path."""
|
|
234
243
|
parsed = urlparse(probe.url)
|
|
@@ -240,7 +249,16 @@ def _assign_http_probe(
|
|
|
240
249
|
return _assign_by_port_8202(services)
|
|
241
250
|
if port == 8100:
|
|
242
251
|
return _assign_by_port_8100(services, path_lower)
|
|
243
|
-
|
|
252
|
+
|
|
253
|
+
if port_map and port in port_map:
|
|
254
|
+
preferred = port_map[port]
|
|
255
|
+
result = _find_service_by_name(services, preferred)
|
|
256
|
+
if result:
|
|
257
|
+
return result
|
|
258
|
+
if preferred == "backend":
|
|
259
|
+
return _find_service_by_name(services, "backend")
|
|
260
|
+
return None
|
|
261
|
+
|
|
244
262
|
return _assign_by_connect_backend(services, path_lower)
|
|
245
263
|
|
|
246
264
|
|
|
@@ -283,13 +301,17 @@ def _assign_by_path_prefix(
|
|
|
283
301
|
return None
|
|
284
302
|
|
|
285
303
|
|
|
286
|
-
def assign_probe_to_service(
|
|
304
|
+
def assign_probe_to_service(
|
|
305
|
+
probe: ProbeTarget,
|
|
306
|
+
services: Sequence[ServiceConfig],
|
|
307
|
+
port_map: Optional[Dict[int, str]] = None,
|
|
308
|
+
) -> Optional[str]:
|
|
287
309
|
"""Map a probe URL/path to a configured WUP service name."""
|
|
288
310
|
path = urlparse(probe.url).path if probe.url.startswith("http") else probe.url
|
|
289
311
|
path_lower = path.lower()
|
|
290
312
|
|
|
291
313
|
if probe.url.startswith("http"):
|
|
292
|
-
result = _assign_http_probe(probe, services, path_lower)
|
|
314
|
+
result = _assign_http_probe(probe, services, path_lower, port_map=port_map)
|
|
293
315
|
if result:
|
|
294
316
|
return result
|
|
295
317
|
return None
|
|
@@ -333,6 +355,67 @@ class TestQLMonitor:
|
|
|
333
355
|
self.scenarios_dir = project_root / (tq.scenario_dir or "testql-scenarios")
|
|
334
356
|
self.discovery = TestQLEndpointDiscovery(str(self.scenarios_dir))
|
|
335
357
|
|
|
358
|
+
def _load_dot_env(self) -> Dict[str, str]:
|
|
359
|
+
"""Read key=value pairs from .env in project root (best-effort)."""
|
|
360
|
+
env: Dict[str, str] = {}
|
|
361
|
+
dot_env = self.project_root / ".env"
|
|
362
|
+
if not dot_env.exists():
|
|
363
|
+
return env
|
|
364
|
+
try:
|
|
365
|
+
for line in dot_env.read_text(encoding="utf-8").splitlines():
|
|
366
|
+
line = line.strip()
|
|
367
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
368
|
+
continue
|
|
369
|
+
key, _, value = line.partition("=")
|
|
370
|
+
env[key.strip()] = value.strip().strip("'\"")
|
|
371
|
+
except OSError:
|
|
372
|
+
pass
|
|
373
|
+
return env
|
|
374
|
+
|
|
375
|
+
def _build_port_map(self) -> Dict[int, str]:
|
|
376
|
+
"""Build host-port → wup service name map from docker-compose files + .env."""
|
|
377
|
+
from .monitoring_manifest import (
|
|
378
|
+
_map_docker_to_wup_service,
|
|
379
|
+
discover_docker_compose_services,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
dot_env = self._load_dot_env()
|
|
383
|
+
|
|
384
|
+
def resolve_port(raw: str) -> Optional[int]:
|
|
385
|
+
"""Resolve host port from mapping, substituting .env vars before defaults."""
|
|
386
|
+
text = raw.strip().strip("'\"")
|
|
387
|
+
if ":" not in text:
|
|
388
|
+
return None
|
|
389
|
+
# Split on the LAST colon to correctly separate host:container
|
|
390
|
+
# (${VAR:-default} syntax contains colons internally)
|
|
391
|
+
host_part = text.rsplit(":", 1)[0]
|
|
392
|
+
try:
|
|
393
|
+
return int(host_part)
|
|
394
|
+
except ValueError:
|
|
395
|
+
pass
|
|
396
|
+
# Replace ${VAR:-default} using .env then fall back to default
|
|
397
|
+
def _replace(m: "re.Match[str]") -> str:
|
|
398
|
+
inner = m.group(0)[2:-1] # strip ${ and }
|
|
399
|
+
var, _, default = inner.partition(":-")
|
|
400
|
+
return dot_env.get(var, default)
|
|
401
|
+
resolved = re.sub(r"\$\{[^}]+\}", _replace, host_part)
|
|
402
|
+
try:
|
|
403
|
+
return int(resolved)
|
|
404
|
+
except ValueError:
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
wup_names = [s.name for s in self.config.services]
|
|
408
|
+
port_map: Dict[int, str] = {}
|
|
409
|
+
for docker_svc in discover_docker_compose_services(self.project_root):
|
|
410
|
+
mapped = _map_docker_to_wup_service(docker_svc, wup_names)
|
|
411
|
+
if not mapped:
|
|
412
|
+
continue
|
|
413
|
+
for raw_port in docker_svc.host_ports:
|
|
414
|
+
host_port = resolve_port(raw_port)
|
|
415
|
+
if host_port:
|
|
416
|
+
port_map[host_port] = mapped
|
|
417
|
+
return port_map
|
|
418
|
+
|
|
336
419
|
def _service_map_paths(self) -> List[Path]:
|
|
337
420
|
globs = self.config.testql.service_map_globs or []
|
|
338
421
|
paths: List[Path] = []
|
|
@@ -340,11 +423,69 @@ class TestQLMonitor:
|
|
|
340
423
|
paths.extend(sorted(self.project_root.glob(pattern)))
|
|
341
424
|
return paths
|
|
342
425
|
|
|
426
|
+
def _add_hardware_usb_module_endpoints(self, accumulator: "_ProbeAccumulator") -> None:
|
|
427
|
+
"""Expand hardware_usb_modules from wup.yaml into OqlOS + proxy API probes."""
|
|
428
|
+
raw = getattr(self.config.testql, "hardware_usb_modules", None) or {}
|
|
429
|
+
if not isinstance(raw, dict) or not raw:
|
|
430
|
+
return
|
|
431
|
+
oqlos = str(raw.get("oqlos_url") or "http://localhost:8202").rstrip("/")
|
|
432
|
+
proxy = str(raw.get("proxy_url") or "http://localhost:8096").rstrip("/")
|
|
433
|
+
module_ids = raw.get("module_ids") or []
|
|
434
|
+
if not isinstance(module_ids, list):
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
catalog: List[Tuple[str, ProbeTarget]] = [
|
|
438
|
+
(
|
|
439
|
+
"firmware",
|
|
440
|
+
ProbeTarget(
|
|
441
|
+
url=f"{oqlos}/api/v1/hardware/identify",
|
|
442
|
+
source="wup.yaml:hardware_usb_modules",
|
|
443
|
+
),
|
|
444
|
+
),
|
|
445
|
+
(
|
|
446
|
+
"connect-scenario",
|
|
447
|
+
ProbeTarget(
|
|
448
|
+
url=f"{proxy}/api/v3/hardware/identify",
|
|
449
|
+
source="wup.yaml:hardware_usb_modules",
|
|
450
|
+
),
|
|
451
|
+
),
|
|
452
|
+
]
|
|
453
|
+
for module_id in module_ids:
|
|
454
|
+
mid = str(module_id).strip()
|
|
455
|
+
if not mid:
|
|
456
|
+
continue
|
|
457
|
+
catalog.append(
|
|
458
|
+
(
|
|
459
|
+
"firmware",
|
|
460
|
+
ProbeTarget(
|
|
461
|
+
url=f"{oqlos}/api/v1/plugins/{mid}/health",
|
|
462
|
+
source="wup.yaml:hardware_usb_modules:plugin_health",
|
|
463
|
+
),
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
catalog.append(
|
|
467
|
+
(
|
|
468
|
+
"connect-scenario",
|
|
469
|
+
ProbeTarget(
|
|
470
|
+
url=f"{proxy}/api/v3/hardware/peripheral-status/{mid}",
|
|
471
|
+
source="wup.yaml:hardware_usb_modules",
|
|
472
|
+
),
|
|
473
|
+
)
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
for service, probe in catalog:
|
|
477
|
+
if not is_monitoring_probe(probe):
|
|
478
|
+
continue
|
|
479
|
+
if probe.source.endswith(":plugin_health"):
|
|
480
|
+
continue
|
|
481
|
+
accumulator.add(service, probe)
|
|
482
|
+
|
|
343
483
|
def _add_config_endpoints(
|
|
344
484
|
self,
|
|
345
485
|
accumulator: "_ProbeAccumulator",
|
|
346
486
|
) -> None:
|
|
347
487
|
"""Add config-declared endpoints (paths or full URLs) per-service base URL."""
|
|
488
|
+
self._add_hardware_usb_module_endpoints(accumulator)
|
|
348
489
|
for svc_name, paths in (self.config.testql.endpoints_by_service or {}).items():
|
|
349
490
|
base = self._resolve_base_url_for_service(svc_name)
|
|
350
491
|
for path in paths:
|
|
@@ -371,26 +512,28 @@ class TestQLMonitor:
|
|
|
371
512
|
def _add_scenario_probes(
|
|
372
513
|
self,
|
|
373
514
|
accumulator: "_ProbeAccumulator",
|
|
515
|
+
port_map: Optional[Dict[int, str]] = None,
|
|
374
516
|
) -> None:
|
|
375
517
|
"""Add TestQL scenario probes mapped to services."""
|
|
376
518
|
for scenario in self.discovery.discover_scenarios():
|
|
377
519
|
for probe in parse_scenario_probes(scenario):
|
|
378
520
|
if not is_monitoring_probe(probe):
|
|
379
521
|
continue
|
|
380
|
-
assigned = assign_probe_to_service(probe, self.config.services)
|
|
522
|
+
assigned = assign_probe_to_service(probe, self.config.services, port_map=port_map)
|
|
381
523
|
if assigned:
|
|
382
524
|
accumulator.add(assigned, probe)
|
|
383
525
|
|
|
384
526
|
def _add_service_map_probes(
|
|
385
527
|
self,
|
|
386
528
|
accumulator: "_ProbeAccumulator",
|
|
529
|
+
port_map: Optional[Dict[int, str]] = None,
|
|
387
530
|
) -> None:
|
|
388
531
|
"""Add service-map TOON/YAML probes mapped to services."""
|
|
389
532
|
for map_path in self._service_map_paths():
|
|
390
533
|
for probe in parse_service_map_probes(map_path):
|
|
391
534
|
if not is_monitoring_probe(probe):
|
|
392
535
|
continue
|
|
393
|
-
assigned = assign_probe_to_service(probe, self.config.services)
|
|
536
|
+
assigned = assign_probe_to_service(probe, self.config.services, port_map=port_map)
|
|
394
537
|
if assigned:
|
|
395
538
|
accumulator.add(assigned, probe)
|
|
396
539
|
|
|
@@ -401,8 +544,9 @@ class TestQLMonitor:
|
|
|
401
544
|
self._add_config_endpoints(accumulator)
|
|
402
545
|
|
|
403
546
|
if self.config.testql.endpoint_discovery:
|
|
404
|
-
self.
|
|
405
|
-
self.
|
|
547
|
+
port_map = self._build_port_map()
|
|
548
|
+
self._add_scenario_probes(accumulator, port_map=port_map)
|
|
549
|
+
self._add_service_map_probes(accumulator, port_map=port_map)
|
|
406
550
|
|
|
407
551
|
return accumulator.by_service
|
|
408
552
|
|
|
@@ -449,16 +593,37 @@ class TestQLMonitor:
|
|
|
449
593
|
return [p for p in merged if p.url.startswith("http://") or p.url.startswith("https://")]
|
|
450
594
|
|
|
451
595
|
@staticmethod
|
|
452
|
-
def _sort_probes_for_live(
|
|
453
|
-
|
|
596
|
+
def _sort_probes_for_live(
|
|
597
|
+
probes: Sequence[ProbeTarget],
|
|
598
|
+
service: str = "",
|
|
599
|
+
) -> List[ProbeTarget]:
|
|
600
|
+
"""Prefer wup.yaml endpoints, then /health URLs, before other scenario probes."""
|
|
454
601
|
|
|
455
|
-
def rank(probe: ProbeTarget) -> Tuple[int, str]:
|
|
602
|
+
def rank(probe: ProbeTarget) -> Tuple[int, int, int, str]:
|
|
456
603
|
source = probe.source or ""
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
if source.startswith("wup.yaml:
|
|
460
|
-
|
|
461
|
-
|
|
604
|
+
path = (urlparse(probe.url).path or probe.url).lower()
|
|
605
|
+
healthish = "/health" in path and "/execution/" not in path
|
|
606
|
+
if source.startswith("wup.yaml:hardware_usb_modules"):
|
|
607
|
+
tier = 0
|
|
608
|
+
elif source.startswith("wup.yaml:endpoints_by_service"):
|
|
609
|
+
tier = 1
|
|
610
|
+
elif source.startswith("wup.yaml:explicit_endpoints"):
|
|
611
|
+
tier = 2
|
|
612
|
+
else:
|
|
613
|
+
tier = 3
|
|
614
|
+
port = urlparse(probe.url).port if probe.url.startswith("http") else None
|
|
615
|
+
# Bench uses host OqlOS on :8202; :8100/firmware proxy often 503 without full make dev.
|
|
616
|
+
port_rank = 0
|
|
617
|
+
if service == "firmware" and port is not None:
|
|
618
|
+
if port == 8202:
|
|
619
|
+
port_rank = 0
|
|
620
|
+
elif port == 8100:
|
|
621
|
+
port_rank = 1
|
|
622
|
+
else:
|
|
623
|
+
port_rank = 2
|
|
624
|
+
health_rank = 0 if healthish else 1
|
|
625
|
+
# Prefer /health liveness before heavier USB identify (tier-0 config).
|
|
626
|
+
return (health_rank, tier, port_rank, probe.url)
|
|
462
627
|
|
|
463
628
|
return sorted(probes, key=rank)
|
|
464
629
|
|
|
@@ -475,7 +640,7 @@ class TestQLMonitor:
|
|
|
475
640
|
return True, ""
|
|
476
641
|
|
|
477
642
|
failed: List[str] = []
|
|
478
|
-
ordered = self._sort_probes_for_live(probes)
|
|
643
|
+
ordered = self._sort_probes_for_live(probes, service=service)
|
|
479
644
|
for probe in ordered[:max_count]:
|
|
480
645
|
ok, detail = probe.probe(timeout_s=timeout_s)
|
|
481
646
|
if ok:
|
|
@@ -323,6 +323,35 @@ class TestQLWatcher(WupWatcher):
|
|
|
323
323
|
return [candidate]
|
|
324
324
|
return []
|
|
325
325
|
|
|
326
|
+
def _resolve_scenario_path(self, scenario: str) -> Optional[Path]:
|
|
327
|
+
"""Resolve a pinned scenario path relative to scenarios_dir or project root."""
|
|
328
|
+
name = (scenario or "").strip()
|
|
329
|
+
if not name:
|
|
330
|
+
return None
|
|
331
|
+
path = Path(name)
|
|
332
|
+
if path.is_absolute() and path.exists():
|
|
333
|
+
return path
|
|
334
|
+
for base in (self.scenarios_dir, self.project_root):
|
|
335
|
+
candidate = base / name
|
|
336
|
+
if candidate.exists():
|
|
337
|
+
return candidate
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
@staticmethod
|
|
341
|
+
def _testql_trailing_json_ok(result: subprocess.CompletedProcess) -> bool:
|
|
342
|
+
"""True when TestQL --output json ends with ok:true and zero failed steps."""
|
|
343
|
+
blob = (result.stdout or "").strip()
|
|
344
|
+
start = blob.rfind("{")
|
|
345
|
+
if start < 0:
|
|
346
|
+
return False
|
|
347
|
+
try:
|
|
348
|
+
data = json.loads(blob[start:])
|
|
349
|
+
except json.JSONDecodeError:
|
|
350
|
+
return False
|
|
351
|
+
if not isinstance(data, dict):
|
|
352
|
+
return False
|
|
353
|
+
return data.get("ok") is True and int(data.get("failed", 1)) == 0
|
|
354
|
+
|
|
326
355
|
@staticmethod
|
|
327
356
|
def _health_summary_all_passed(summary: str) -> bool:
|
|
328
357
|
"""True when summary looks like N/N passed with zero failures."""
|
|
@@ -341,6 +370,12 @@ class TestQLWatcher(WupWatcher):
|
|
|
341
370
|
limit = (svc_config.quick_tests.max_endpoints
|
|
342
371
|
if svc_config and svc_config.quick_tests else self.quick_limit)
|
|
343
372
|
|
|
373
|
+
pinned = svc_config.quick_tests.scenario if svc_config and svc_config.quick_tests else ""
|
|
374
|
+
if pinned:
|
|
375
|
+
resolved = self._resolve_scenario_path(pinned)
|
|
376
|
+
if resolved:
|
|
377
|
+
return [resolved][:limit]
|
|
378
|
+
|
|
344
379
|
# Filter scenarios by service type
|
|
345
380
|
svc_type = svc_config.type if svc_config else "auto"
|
|
346
381
|
if getattr(self.config.testql, "quick_smoke_only", False):
|
|
@@ -350,6 +385,15 @@ class TestQLWatcher(WupWatcher):
|
|
|
350
385
|
|
|
351
386
|
filtered_scenarios = self._filter_scenarios_by_type(all_scenarios, svc_type)
|
|
352
387
|
|
|
388
|
+
# connect-workshop / auto-api-connect-scenario-* also match token "connect".
|
|
389
|
+
if service == "connect-scenario":
|
|
390
|
+
filtered_scenarios = [
|
|
391
|
+
s
|
|
392
|
+
for s in filtered_scenarios
|
|
393
|
+
if s.name.lower().startswith("connect-scenario-wup-")
|
|
394
|
+
or s.name.lower().startswith("connect-scenario-modbus-")
|
|
395
|
+
]
|
|
396
|
+
|
|
353
397
|
selected = self._get_scored_scenarios(filtered_scenarios, self._tokenize_service(service), limit)
|
|
354
398
|
if selected:
|
|
355
399
|
return selected
|
|
@@ -623,7 +667,8 @@ class TestQLWatcher(WupWatcher):
|
|
|
623
667
|
failed = data.get("failed")
|
|
624
668
|
if not isinstance(passed, int) or not isinstance(failed, int):
|
|
625
669
|
return None
|
|
626
|
-
|
|
670
|
+
steps = data.get("steps")
|
|
671
|
+
total = steps if isinstance(steps, int) and steps > 0 else passed + failed
|
|
627
672
|
errors = data.get("errors") or []
|
|
628
673
|
hint = f" — {errors[0]}" if errors else ""
|
|
629
674
|
return f"{passed}/{total} passed, {failed} failed{hint}"
|
|
@@ -688,7 +733,11 @@ class TestQLWatcher(WupWatcher):
|
|
|
688
733
|
args = ["run", str(scenario_path), "--output", "json", *self.testql_extra_args]
|
|
689
734
|
result = self._run_testql(args, timeout=max(int(self._quick_probe_timeout()), 120))
|
|
690
735
|
summary = self._summarize_health_scenario_failure(result)
|
|
691
|
-
if
|
|
736
|
+
if (
|
|
737
|
+
result.returncode == 0
|
|
738
|
+
or self._testql_trailing_json_ok(result)
|
|
739
|
+
or self._health_summary_all_passed(summary)
|
|
740
|
+
):
|
|
692
741
|
self._record_health_transition(
|
|
693
742
|
service=fleet,
|
|
694
743
|
status="up",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wup
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.65
|
|
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
|
|
@@ -31,17 +31,17 @@ Dynamic: license-file
|
|
|
31
31
|
|
|
32
32
|
## AI Cost Tracking
|
|
33
33
|
|
|
34
|
-
     
|
|
35
|
+
  
|
|
36
36
|
|
|
37
|
-
- 🤖 **LLM usage:** $3.
|
|
38
|
-
- 👤 **Human dev:** ~$
|
|
37
|
+
- 🤖 **LLM usage:** $3.4955 (77 commits)
|
|
38
|
+
- 👤 **Human dev:** ~$3318 (33.2h @ $100/h, 30min dedup)
|
|
39
39
|
|
|
40
|
-
Generated on 2026-
|
|
40
|
+
Generated on 2026-06-03 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
41
41
|
|
|
42
42
|
---
|
|
43
43
|
|
|
44
|
-
    
|
|
45
45
|
|
|
46
46
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
47
47
|
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|