wup 0.2.82__tar.gz → 0.2.83__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.82 → wup-0.2.83}/PKG-INFO +9 -9
- {wup-0.2.82 → wup-0.2.83}/README.md +6 -6
- {wup-0.2.82 → wup-0.2.83}/pyproject.toml +3 -3
- {wup-0.2.82 → wup-0.2.83}/tests/test_control.py +3 -1
- {wup-0.2.82 → wup-0.2.83}/tests/test_endpoints_init_cli.py +8 -2
- wup-0.2.83/tests/test_realtime_anomalies.py +214 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_service_inference.py +4 -2
- {wup-0.2.82 → wup-0.2.83}/tests/test_status_data.py +4 -1
- {wup-0.2.82 → wup-0.2.83}/tests/test_testql_watcher.py +44 -3
- {wup-0.2.82 → wup-0.2.83}/tests/test_wup.py +5 -3
- {wup-0.2.82 → wup-0.2.83}/wup/__init__.py +1 -1
- {wup-0.2.82 → wup-0.2.83}/wup/cli.py +79 -0
- {wup-0.2.82 → wup-0.2.83}/wup/core.py +109 -17
- {wup-0.2.82 → wup-0.2.83}/wup/event_store.py +24 -2
- wup-0.2.83/wup/realtime_anomalies.py +145 -0
- {wup-0.2.82 → wup-0.2.83}/wup/testql_monitor.py +33 -7
- {wup-0.2.82 → wup-0.2.83}/wup/testql_watcher.py +29 -3
- {wup-0.2.82 → wup-0.2.83}/wup.egg-info/PKG-INFO +9 -9
- {wup-0.2.82 → wup-0.2.83}/wup.egg-info/SOURCES.txt +2 -0
- {wup-0.2.82 → wup-0.2.83}/wup.egg-info/requires.txt +6 -2
- {wup-0.2.82 → wup-0.2.83}/LICENSE +0 -0
- {wup-0.2.82 → wup-0.2.83}/setup.cfg +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_aql.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_assistant.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_auto_detection.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_bootstrap.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_cli_bridge.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_cli_filtering.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_discovery_adapters.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_e2e.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_genericity.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_health_summary_passed.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_intent_monitor.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_monitoring_manifest.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_multi_project.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_oql.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_planfile_reporter_dedupe.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_probe_mutex.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_sync.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_testql_monitor.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_visual_diff_periodic_skip.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_visual_diff_progress.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_watch_exclude.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_watch_no_paths_exit.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_web_client.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/tests/test_wup_generate.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/_ast_detector.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/_base_detector.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/_hash_detector.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/_yaml_detector.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/anomaly_detector.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/anomaly_models.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/aql.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/assistant.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/assistant_discovery.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/assistant_validator.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/bootstrap.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/bus.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/cli_bridge.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/cli_config_generator.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/cli_scanner.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/config.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/control.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/dependency_mapper.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/discovery.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/endpoints.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/file_watcher/events/file_events.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/generate.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/init_cli.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/intent_monitor.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/models/__init__.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/models/config.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/models/target.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/monitoring_manifest.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/multi.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/oql.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/paths.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/planfile_reporter.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/status_data.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/sync.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/testing/events/health_events.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/testing/events/test_results.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/testing/handlers/event_handlers.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/testing/handlers/health_handlers.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/testing/queries/health_queries.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/testql_cli_generator.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/testql_discovery.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/validate.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/visual_diff.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup/web_client.py +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.82 → wup-0.2.83}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.82 → wup-0.2.83}/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.83
|
|
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
|
|
@@ -25,9 +25,9 @@ Provides-Extra: dev
|
|
|
25
25
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
26
26
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
27
27
|
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
28
|
-
Requires-Dist: goal>=2.1.0; extra == "dev"
|
|
28
|
+
Requires-Dist: goal>=2.1.0; python_version >= "3.12" and extra == "dev"
|
|
29
29
|
Requires-Dist: costs>=0.1.20; extra == "dev"
|
|
30
|
-
Requires-Dist: pfix>=0.1.60; extra == "dev"
|
|
30
|
+
Requires-Dist: pfix>=0.1.60; python_version >= "3.10" and extra == "dev"
|
|
31
31
|
Requires-Dist: uri2wup; extra == "dev"
|
|
32
32
|
Requires-Dist: dsl2wup; extra == "dev"
|
|
33
33
|
Requires-Dist: nlp2wup; extra == "dev"
|
|
@@ -47,17 +47,17 @@ Dynamic: license-file
|
|
|
47
47
|
|
|
48
48
|
## AI Cost Tracking
|
|
49
49
|
|
|
50
|
-
    
|
|
51
|
+
  
|
|
52
52
|
|
|
53
|
-
- 🤖 **LLM usage:** $
|
|
54
|
-
- 👤 **Human dev:** ~$
|
|
53
|
+
- 🤖 **LLM usage:** $6.8315 (120 commits)
|
|
54
|
+
- 👤 **Human dev:** ~$6379 (63.8h @ $100/h, 30min dedup)
|
|
55
55
|
|
|
56
|
-
Generated on 2026-
|
|
56
|
+
Generated on 2026-09-11 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
57
57
|
|
|
58
58
|
---
|
|
59
59
|
|
|
60
|
-
    
|
|
61
61
|
|
|
62
62
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
63
63
|
|
|
@@ -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:** $6.8315 (120 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$6379 (63.8h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
|
-
Generated on 2026-
|
|
12
|
+
Generated on 2026-09-11 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
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "wup"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.83"
|
|
8
8
|
description = "WUP (What's Up) - Intelligent file watcher for regression testing in large projects"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -34,9 +34,9 @@ dev = [
|
|
|
34
34
|
"pytest>=7.0.0",
|
|
35
35
|
"pytest-cov>=5.0",
|
|
36
36
|
"ruff>=0.8",
|
|
37
|
-
"goal>=2.1.0",
|
|
37
|
+
"goal>=2.1.0; python_version >= '3.12'",
|
|
38
38
|
"costs>=0.1.20",
|
|
39
|
-
"pfix>=0.1.60",
|
|
39
|
+
"pfix>=0.1.60; python_version >= '3.10'",
|
|
40
40
|
"uri2wup",
|
|
41
41
|
"dsl2wup",
|
|
42
42
|
"nlp2wup",
|
|
@@ -13,6 +13,8 @@ def test_dispatch_validate_shim(tmp_path: Path) -> None:
|
|
|
13
13
|
"project:\n name: demo\n description: test\nwatch:\n paths: []\nservices: []\n",
|
|
14
14
|
encoding="utf-8",
|
|
15
15
|
)
|
|
16
|
-
result = dispatch_validate(
|
|
16
|
+
result = dispatch_validate(
|
|
17
|
+
str(config), project=str(tmp_path), default_file=str(tmp_path / "app.doql.less")
|
|
18
|
+
)
|
|
17
19
|
assert "ok" in result
|
|
18
20
|
assert result["action"] == "validate"
|
|
@@ -38,7 +38,10 @@ def test_discover_testql_endpoints(tmp_path: Path) -> None:
|
|
|
38
38
|
def test_endpoints_via_bus(tmp_path: Path) -> None:
|
|
39
39
|
scen = tmp_path / "scenarios"
|
|
40
40
|
_write_scenario(scen)
|
|
41
|
-
result = dispatch(
|
|
41
|
+
result = dispatch(
|
|
42
|
+
f"ENDPOINTS {scen} OUT {tmp_path / 'out.json'}",
|
|
43
|
+
default_file=str(tmp_path / "app.doql.less"),
|
|
44
|
+
)
|
|
42
45
|
assert result.ok
|
|
43
46
|
assert (tmp_path / "out.json").exists()
|
|
44
47
|
|
|
@@ -50,7 +53,10 @@ def test_init_cli_via_bus(tmp_path: Path) -> None:
|
|
|
50
53
|
)
|
|
51
54
|
(tmp_path / "demo").mkdir()
|
|
52
55
|
(tmp_path / "demo" / "__init__.py").write_text("def main(): pass\n", encoding="utf-8")
|
|
53
|
-
result = dispatch(
|
|
56
|
+
result = dispatch(
|
|
57
|
+
f"INIT_CLI {tmp_path} OUT wup.yaml SCENARIOS testql-scenarios",
|
|
58
|
+
default_file=str(tmp_path / "app.doql.less"),
|
|
59
|
+
)
|
|
54
60
|
assert result.ok
|
|
55
61
|
assert (tmp_path / "wup.yaml").exists()
|
|
56
62
|
assert (tmp_path / "testql-scenarios").is_dir()
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Tests for realtime anomaly helpers and their integration in the watch loop."""
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from wup.config import WupConfig, ProjectConfig, WatchConfig, ServiceConfig, ServiceTestConfig, TestStrategyConfig, TestQLConfig
|
|
7
|
+
from wup.core import WupWatcher
|
|
8
|
+
from wup.realtime_anomalies import BurstAnomaly, ChangeBurstDetector, LatencyTracker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestLatencyTracker:
|
|
12
|
+
def test_no_anomaly_below_min_samples(self):
|
|
13
|
+
tracker = LatencyTracker()
|
|
14
|
+
for _ in range(7):
|
|
15
|
+
assert tracker.record("/ep", 10.0) is None
|
|
16
|
+
|
|
17
|
+
def test_flags_spike_over_baseline(self):
|
|
18
|
+
tracker = LatencyTracker(
|
|
19
|
+
min_samples=5,
|
|
20
|
+
ratio_threshold=3.0,
|
|
21
|
+
absolute_floor_ms=50.0,
|
|
22
|
+
confirm_samples=1,
|
|
23
|
+
)
|
|
24
|
+
for _ in range(5):
|
|
25
|
+
tracker.record("/ep", 20.0)
|
|
26
|
+
anomaly = tracker.record("/ep", 200.0)
|
|
27
|
+
assert anomaly is not None
|
|
28
|
+
assert anomaly.endpoint == "/ep"
|
|
29
|
+
assert anomaly.ratio >= 3.0
|
|
30
|
+
|
|
31
|
+
def test_no_flag_when_latency_below_absolute_floor(self):
|
|
32
|
+
tracker = LatencyTracker(min_samples=5, ratio_threshold=3.0, absolute_floor_ms=50.0)
|
|
33
|
+
for _ in range(5):
|
|
34
|
+
tracker.record("/ep", 5.0)
|
|
35
|
+
# ratio is huge but latency (15ms) is under the 50ms floor
|
|
36
|
+
assert tracker.record("/ep", 15.0) is None
|
|
37
|
+
|
|
38
|
+
def test_no_ratio_flag_for_trivial_baseline_blip(self):
|
|
39
|
+
# Dev-host reality: idle endpoint at ~1 ms, ordinary load blip at
|
|
40
|
+
# 74 ms. Ratio is meaningless there and must not flag (PLF-2229).
|
|
41
|
+
tracker = LatencyTracker(min_samples=5, confirm_samples=1)
|
|
42
|
+
for _ in range(5):
|
|
43
|
+
tracker.record("/ep", 1.0)
|
|
44
|
+
assert tracker.record("/ep", 74.0) is None
|
|
45
|
+
assert tracker.record("/ep", 120.0) is None
|
|
46
|
+
|
|
47
|
+
def test_trivial_baseline_flags_on_sustained_absolute_slowness(self):
|
|
48
|
+
tracker = LatencyTracker(
|
|
49
|
+
min_samples=5,
|
|
50
|
+
baseline_floor_ms=10.0,
|
|
51
|
+
slow_absolute_ms=300.0,
|
|
52
|
+
confirm_samples=3,
|
|
53
|
+
)
|
|
54
|
+
for _ in range(5):
|
|
55
|
+
tracker.record("/ep", 1.0)
|
|
56
|
+
assert tracker.record("/ep", 800.0) is None
|
|
57
|
+
assert tracker.record("/ep", 800.0) is None
|
|
58
|
+
anomaly = tracker.record("/ep", 800.0)
|
|
59
|
+
assert anomaly is not None
|
|
60
|
+
assert anomaly.latency_ms == 800.0
|
|
61
|
+
|
|
62
|
+
def test_sustained_blip_below_slow_absolute_never_flags(self):
|
|
63
|
+
tracker = LatencyTracker(
|
|
64
|
+
min_samples=5,
|
|
65
|
+
baseline_floor_ms=10.0,
|
|
66
|
+
slow_absolute_ms=300.0,
|
|
67
|
+
confirm_samples=3,
|
|
68
|
+
)
|
|
69
|
+
for _ in range(5):
|
|
70
|
+
tracker.record("/ep", 2.0)
|
|
71
|
+
for _ in range(10):
|
|
72
|
+
assert tracker.record("/ep", 120.0) is None
|
|
73
|
+
|
|
74
|
+
def test_ratio_anomaly_needs_consecutive_confirmation(self):
|
|
75
|
+
tracker = LatencyTracker(
|
|
76
|
+
min_samples=5,
|
|
77
|
+
ratio_threshold=3.0,
|
|
78
|
+
absolute_floor_ms=50.0,
|
|
79
|
+
confirm_samples=3,
|
|
80
|
+
)
|
|
81
|
+
for _ in range(5):
|
|
82
|
+
tracker.record("/ep", 20.0)
|
|
83
|
+
assert tracker.record("/ep", 200.0) is None
|
|
84
|
+
# single fast sample resets the streak
|
|
85
|
+
assert tracker.record("/ep", 20.0) is None
|
|
86
|
+
assert tracker.record("/ep", 200.0) is None
|
|
87
|
+
assert tracker.record("/ep", 200.0) is None
|
|
88
|
+
anomaly = tracker.record("/ep", 200.0)
|
|
89
|
+
assert anomaly is not None
|
|
90
|
+
assert anomaly.ratio >= 3.0
|
|
91
|
+
|
|
92
|
+
def test_reports_once_per_episode_then_rearms(self):
|
|
93
|
+
tracker = LatencyTracker(
|
|
94
|
+
min_samples=5,
|
|
95
|
+
ratio_threshold=3.0,
|
|
96
|
+
absolute_floor_ms=50.0,
|
|
97
|
+
confirm_samples=2,
|
|
98
|
+
)
|
|
99
|
+
for _ in range(5):
|
|
100
|
+
tracker.record("/ep", 20.0)
|
|
101
|
+
assert tracker.record("/ep", 200.0) is None
|
|
102
|
+
first = tracker.record("/ep", 220.0)
|
|
103
|
+
assert first is not None
|
|
104
|
+
# sustained slowness: no further reports until latency recovers
|
|
105
|
+
assert tracker.record("/ep", 240.0) is None
|
|
106
|
+
assert tracker.record("/ep", 260.0) is None
|
|
107
|
+
# recovery re-arms the tracker
|
|
108
|
+
assert tracker.record("/ep", 20.0) is None
|
|
109
|
+
assert tracker.record("/ep", 200.0) is None
|
|
110
|
+
second = tracker.record("/ep", 200.0)
|
|
111
|
+
assert second is not None
|
|
112
|
+
|
|
113
|
+
def test_baseline_unlearned_until_min_samples(self):
|
|
114
|
+
tracker = LatencyTracker(min_samples=5)
|
|
115
|
+
assert tracker.baseline("/ep") is None
|
|
116
|
+
for _ in range(5):
|
|
117
|
+
tracker.record("/ep", 10.0)
|
|
118
|
+
assert tracker.baseline("/ep") == 10.0
|
|
119
|
+
|
|
120
|
+
def test_slow_endpoint_never_flagged_when_consistently_slow(self):
|
|
121
|
+
tracker = LatencyTracker(min_samples=5)
|
|
122
|
+
for _ in range(10):
|
|
123
|
+
assert tracker.record("/ep", 900.0) is None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TestChangeBurstDetector:
|
|
127
|
+
def test_no_burst_under_threshold(self):
|
|
128
|
+
detector = ChangeBurstDetector(burst_threshold=5, window_s=10.0)
|
|
129
|
+
for i in range(4):
|
|
130
|
+
assert detector.record("svc", now=100.0 + i) is None
|
|
131
|
+
|
|
132
|
+
def test_burst_reported_once_then_rearmed(self):
|
|
133
|
+
detector = ChangeBurstDetector(burst_threshold=5, window_s=10.0)
|
|
134
|
+
anomalies = [detector.record("svc", now=100.0 + i * 0.1) for i in range(6)]
|
|
135
|
+
# 5th event (index 4) crosses the threshold -> reported once
|
|
136
|
+
reported = [a for a in anomalies if a is not None]
|
|
137
|
+
assert len(reported) == 1
|
|
138
|
+
assert isinstance(reported[0], BurstAnomaly)
|
|
139
|
+
assert reported[0].service == "svc"
|
|
140
|
+
assert reported[0].events == 5
|
|
141
|
+
# detector re-armed: next few events within window report nothing
|
|
142
|
+
for i in range(3):
|
|
143
|
+
assert detector.record("svc", now=100.5 + i * 0.1) is None
|
|
144
|
+
|
|
145
|
+
def test_old_events_expire_out_of_window(self):
|
|
146
|
+
detector = ChangeBurstDetector(burst_threshold=5, window_s=10.0)
|
|
147
|
+
for _ in range(4):
|
|
148
|
+
detector.record("svc", now=100.0)
|
|
149
|
+
# long gap: window slides past previous events
|
|
150
|
+
assert detector.record("svc", now=200.0) is None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestWatcherDebounce:
|
|
154
|
+
def _make_watcher(self, tmpdir, service="users"):
|
|
155
|
+
(Path(tmpdir) / "app" / service).mkdir(parents=True, exist_ok=True)
|
|
156
|
+
config = WupConfig(
|
|
157
|
+
project=ProjectConfig(name="test"),
|
|
158
|
+
watch=WatchConfig(paths=["app/**"]),
|
|
159
|
+
services=[ServiceConfig(
|
|
160
|
+
name=service,
|
|
161
|
+
root=f"app/{service}",
|
|
162
|
+
paths=[f"app/{service}/**"],
|
|
163
|
+
quick_tests=ServiceTestConfig(scope="all", max_endpoints=3),
|
|
164
|
+
)],
|
|
165
|
+
test_strategy=TestStrategyConfig(),
|
|
166
|
+
testql=TestQLConfig(),
|
|
167
|
+
)
|
|
168
|
+
watcher = WupWatcher(tmpdir, config=config)
|
|
169
|
+
watcher.dependency_mapper.service_to_endpoints[service] = ["/api/users"]
|
|
170
|
+
return watcher
|
|
171
|
+
|
|
172
|
+
def test_single_save_emits_one_quick_test(self):
|
|
173
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
174
|
+
watcher = self._make_watcher(tmpdir)
|
|
175
|
+
file_path = str(Path(tmpdir) / "app" / "users" / "routes.py")
|
|
176
|
+
watcher.on_file_change(file_path)
|
|
177
|
+
watcher.on_file_change(file_path) # cascade: same save, second event
|
|
178
|
+
watcher._pending_event_times["users"] = 0
|
|
179
|
+
watcher._flush_pending_events()
|
|
180
|
+
assert len(watcher.test_queue) == 1
|
|
181
|
+
|
|
182
|
+
def test_burst_event_storm_still_emits_one_test(self):
|
|
183
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
184
|
+
watcher = self._make_watcher(tmpdir)
|
|
185
|
+
file_path = str(Path(tmpdir) / "app" / "users" / "routes.py")
|
|
186
|
+
for _ in range(150):
|
|
187
|
+
watcher.on_file_change(file_path)
|
|
188
|
+
watcher._pending_event_times["users"] = 0
|
|
189
|
+
watcher._flush_pending_events()
|
|
190
|
+
assert len(watcher.test_queue) == 1
|
|
191
|
+
|
|
192
|
+
def test_events_buffered_during_debounce_window(self):
|
|
193
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
194
|
+
watcher = self._make_watcher(tmpdir)
|
|
195
|
+
file_path = str(Path(tmpdir) / "app" / "users" / "routes.py")
|
|
196
|
+
watcher.on_file_change(file_path)
|
|
197
|
+
# window not elapsed: nothing scheduled, event stays buffered
|
|
198
|
+
watcher._flush_pending_events()
|
|
199
|
+
assert len(watcher.test_queue) == 0
|
|
200
|
+
assert watcher._pending_events["users"]
|
|
201
|
+
|
|
202
|
+
def test_cooldown_prevents_rescheduling(self):
|
|
203
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
204
|
+
watcher = self._make_watcher(tmpdir)
|
|
205
|
+
file_path = str(Path(tmpdir) / "app" / "users" / "routes.py")
|
|
206
|
+
watcher.on_file_change(file_path)
|
|
207
|
+
watcher._pending_event_times["users"] = 0
|
|
208
|
+
watcher._flush_pending_events()
|
|
209
|
+
assert len(watcher.test_queue) == 1
|
|
210
|
+
# immediately re-flush with fresh events: cooldown blocks a second test
|
|
211
|
+
watcher.on_file_change(file_path)
|
|
212
|
+
watcher._pending_event_times["users"] = 0
|
|
213
|
+
watcher._flush_pending_events()
|
|
214
|
+
assert len(watcher.test_queue) == 1
|
|
@@ -202,8 +202,10 @@ def test_file_change_uses_configured_services_when_inference_fails():
|
|
|
202
202
|
tested_services = []
|
|
203
203
|
watcher.schedule_quick_test = lambda s: tested_services.append(s)
|
|
204
204
|
|
|
205
|
-
# Trigger file change
|
|
205
|
+
# Trigger file change (event debounced per service)
|
|
206
206
|
watcher.on_file_change(str(test_file))
|
|
207
|
-
|
|
207
|
+
watcher._pending_event_times["my-service"] = 0 # force window elapsed
|
|
208
|
+
watcher._flush_pending_events()
|
|
209
|
+
|
|
208
210
|
# Should test the configured service even though inference failed
|
|
209
211
|
assert "my-service" in tested_services
|
|
@@ -29,6 +29,9 @@ def test_status_via_bus(tmp_path: Path) -> None:
|
|
|
29
29
|
"project:\n name: demo\nwatch:\n paths: []\nservices:\n - name: api\n type: web\n",
|
|
30
30
|
encoding="utf-8",
|
|
31
31
|
)
|
|
32
|
-
result = dispatch(
|
|
32
|
+
result = dispatch(
|
|
33
|
+
f"STATUS PROJECT {tmp_path} FILE wup.yaml",
|
|
34
|
+
default_file=str(tmp_path / "app.doql.less"),
|
|
35
|
+
)
|
|
33
36
|
assert result.ok
|
|
34
37
|
assert result.data["project_name"] == "demo"
|
|
@@ -564,7 +564,7 @@ def test_quick_pass_actions_prefer_config_endpoints_for_visual_diff():
|
|
|
564
564
|
assert differ.calls == [("backend", ["http://localhost:8101/api/v3/health"])]
|
|
565
565
|
|
|
566
566
|
|
|
567
|
-
def
|
|
567
|
+
def test_quick_interrupt_keeps_watcher_active_without_failure_track():
|
|
568
568
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
569
569
|
root = Path(tmpdir)
|
|
570
570
|
scenario_dir = root / "testql-scenarios"
|
|
@@ -592,8 +592,49 @@ def test_quick_interrupt_does_not_create_failure_track():
|
|
|
592
592
|
stderr="",
|
|
593
593
|
)
|
|
594
594
|
|
|
595
|
-
|
|
596
|
-
asyncio.run(watcher.run_quick_test("connect-config", []))
|
|
595
|
+
assert asyncio.run(watcher.run_quick_test("connect-config", [])) is False
|
|
597
596
|
|
|
598
597
|
tracks = list((root / ".wup" / "tracks").glob("*.json"))
|
|
599
598
|
assert tracks == []
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def test_run_quick_test_accepts_parent_service_test_target():
|
|
602
|
+
"""WupWatcher.process_test_queue_once calls run_quick_test(ServiceTestTarget)."""
|
|
603
|
+
from wup.models.target import ServiceTestTarget
|
|
604
|
+
|
|
605
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
606
|
+
root = Path(tmpdir)
|
|
607
|
+
scenario_dir = root / "testql-scenarios"
|
|
608
|
+
scenario_dir.mkdir(parents=True, exist_ok=True)
|
|
609
|
+
(scenario_dir / "backend-smoke.testql.toon.yaml").write_text("name: smoke\n", encoding="utf-8")
|
|
610
|
+
|
|
611
|
+
cfg = WupConfig(
|
|
612
|
+
project=ProjectConfig(name="demo"),
|
|
613
|
+
services=[ServiceConfig(name="backend", paths=["backend/**"])],
|
|
614
|
+
watch=WatchConfig(),
|
|
615
|
+
testql=TestQLConfig(scenario_dir="testql-scenarios"),
|
|
616
|
+
)
|
|
617
|
+
watcher = TestQLWatcher(
|
|
618
|
+
project_root=str(root),
|
|
619
|
+
deps_file=str(root / "deps.json"),
|
|
620
|
+
scenarios_dir="testql-scenarios",
|
|
621
|
+
track_dir=".wup/tracks",
|
|
622
|
+
config=cfg,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
async def skip_probes(service, endpoints):
|
|
626
|
+
return True
|
|
627
|
+
|
|
628
|
+
watcher._run_live_http_probes = skip_probes # type: ignore[method-assign]
|
|
629
|
+
watcher._get_quick_scenarios = lambda service: [] # type: ignore[method-assign]
|
|
630
|
+
|
|
631
|
+
async def no_scenarios(service, merged):
|
|
632
|
+
return True
|
|
633
|
+
|
|
634
|
+
watcher._run_quick_test_no_scenarios = no_scenarios # type: ignore[method-assign]
|
|
635
|
+
|
|
636
|
+
target = ServiceTestTarget(service="backend", endpoints=["http://127.0.0.1/health"])
|
|
637
|
+
assert asyncio.run(watcher.run_quick_test(target)) is True
|
|
638
|
+
|
|
639
|
+
watcher.test_queue.append(("quick", "backend", ["http://127.0.0.1/health"]))
|
|
640
|
+
asyncio.run(watcher.process_test_queue_once())
|
|
@@ -689,13 +689,15 @@ class TestIntegrationWorkflow:
|
|
|
689
689
|
"/api/users/create"
|
|
690
690
|
]
|
|
691
691
|
|
|
692
|
-
# Simulate file change
|
|
692
|
+
# Simulate file change (debounced per service)
|
|
693
693
|
file_path = str(Path(tmpdir) / "app" / "users" / "routes.py")
|
|
694
694
|
watcher.on_file_change(file_path)
|
|
695
|
-
|
|
695
|
+
watcher._pending_event_times["users"] = 0 # force window elapsed
|
|
696
|
+
watcher._flush_pending_events()
|
|
697
|
+
|
|
696
698
|
# Verify service was detected
|
|
697
699
|
assert "users" in watcher.changed_services
|
|
698
|
-
|
|
700
|
+
|
|
699
701
|
# Verify test was scheduled
|
|
700
702
|
assert len(watcher.test_queue) == 1
|
|
701
703
|
test_type, service_name, endpoints = watcher.test_queue[0]
|
|
@@ -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.83"
|
|
11
11
|
__author__ = "Tom Sapletta"
|
|
12
12
|
|
|
13
13
|
from .config import load_config, save_config, get_default_config
|
|
@@ -595,6 +595,85 @@ def _build_status_panel(
|
|
|
595
595
|
return Group(*lines)
|
|
596
596
|
|
|
597
597
|
|
|
598
|
+
@app.command()
|
|
599
|
+
def health(
|
|
600
|
+
config: Optional[str] = typer.Option(None, "--config", "-C", help="Path to wup.yaml config file"),
|
|
601
|
+
since: Optional[int] = typer.Option(None, "--since", help="Only show transitions newer than this unix timestamp"),
|
|
602
|
+
failed_only: bool = typer.Option(False, "--failed-only", help="Only services currently down/degraded"),
|
|
603
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="No output; exit code only"),
|
|
604
|
+
):
|
|
605
|
+
"""Runtime service health snapshot (reads the shared watcher state).
|
|
606
|
+
|
|
607
|
+
Designed for LLM agents and scripts: stable JSON on stdout, exit code
|
|
608
|
+
reflects health (0 = all up, 1 = degraded, 2 = something down,
|
|
609
|
+
3 = no watcher state yet).
|
|
610
|
+
"""
|
|
611
|
+
import json as json_mod
|
|
612
|
+
|
|
613
|
+
project_path = Path(".").resolve()
|
|
614
|
+
from .paths import health_events_path as _health_events_path
|
|
615
|
+
from .paths import health_state_path as _health_state_path
|
|
616
|
+
|
|
617
|
+
state = {}
|
|
618
|
+
state_path = _health_state_path(project_path)
|
|
619
|
+
if state_path.exists():
|
|
620
|
+
try:
|
|
621
|
+
state = json_mod.loads(state_path.read_text(encoding="utf-8"))
|
|
622
|
+
except (OSError, ValueError):
|
|
623
|
+
state = {}
|
|
624
|
+
|
|
625
|
+
if since is not None:
|
|
626
|
+
events_path = _health_events_path(project_path)
|
|
627
|
+
recent = []
|
|
628
|
+
if events_path.exists():
|
|
629
|
+
for line in reversed(events_path.read_text(encoding="utf-8").splitlines()):
|
|
630
|
+
if not line.strip():
|
|
631
|
+
continue
|
|
632
|
+
try:
|
|
633
|
+
record = json_mod.loads(line)
|
|
634
|
+
except ValueError:
|
|
635
|
+
continue
|
|
636
|
+
if int(record.get("timestamp", 0)) < since:
|
|
637
|
+
break
|
|
638
|
+
recent.append(record)
|
|
639
|
+
payload = {
|
|
640
|
+
"project": project_path.name,
|
|
641
|
+
"since": since,
|
|
642
|
+
"transitions": list(reversed(recent)),
|
|
643
|
+
"state": state,
|
|
644
|
+
}
|
|
645
|
+
else:
|
|
646
|
+
payload = {
|
|
647
|
+
"project": project_path.name,
|
|
648
|
+
"state": state,
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
has_state = bool(state)
|
|
652
|
+
if failed_only:
|
|
653
|
+
payload["state"] = {
|
|
654
|
+
name: entry
|
|
655
|
+
for name, entry in payload["state"].items()
|
|
656
|
+
if str(entry.get("status", "")).lower() in ("down", "degraded")
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
statuses = {
|
|
660
|
+
str(entry.get("status", "")).lower()
|
|
661
|
+
for entry in payload["state"].values()
|
|
662
|
+
}
|
|
663
|
+
if "down" in statuses:
|
|
664
|
+
code = 2
|
|
665
|
+
elif "degraded" in statuses:
|
|
666
|
+
code = 1
|
|
667
|
+
elif not has_state:
|
|
668
|
+
code = 3
|
|
669
|
+
else:
|
|
670
|
+
code = 0
|
|
671
|
+
|
|
672
|
+
if not quiet:
|
|
673
|
+
print(json_mod.dumps(payload, ensure_ascii=False, indent=2))
|
|
674
|
+
raise typer.Exit(code)
|
|
675
|
+
|
|
676
|
+
|
|
598
677
|
@app.command()
|
|
599
678
|
def status(
|
|
600
679
|
deps_file: str = typer.Option("deps.json", "--deps", "-d", help="Path to dependency map file"),
|