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.
Files changed (93) hide show
  1. {wup-0.2.82 → wup-0.2.83}/PKG-INFO +9 -9
  2. {wup-0.2.82 → wup-0.2.83}/README.md +6 -6
  3. {wup-0.2.82 → wup-0.2.83}/pyproject.toml +3 -3
  4. {wup-0.2.82 → wup-0.2.83}/tests/test_control.py +3 -1
  5. {wup-0.2.82 → wup-0.2.83}/tests/test_endpoints_init_cli.py +8 -2
  6. wup-0.2.83/tests/test_realtime_anomalies.py +214 -0
  7. {wup-0.2.82 → wup-0.2.83}/tests/test_service_inference.py +4 -2
  8. {wup-0.2.82 → wup-0.2.83}/tests/test_status_data.py +4 -1
  9. {wup-0.2.82 → wup-0.2.83}/tests/test_testql_watcher.py +44 -3
  10. {wup-0.2.82 → wup-0.2.83}/tests/test_wup.py +5 -3
  11. {wup-0.2.82 → wup-0.2.83}/wup/__init__.py +1 -1
  12. {wup-0.2.82 → wup-0.2.83}/wup/cli.py +79 -0
  13. {wup-0.2.82 → wup-0.2.83}/wup/core.py +109 -17
  14. {wup-0.2.82 → wup-0.2.83}/wup/event_store.py +24 -2
  15. wup-0.2.83/wup/realtime_anomalies.py +145 -0
  16. {wup-0.2.82 → wup-0.2.83}/wup/testql_monitor.py +33 -7
  17. {wup-0.2.82 → wup-0.2.83}/wup/testql_watcher.py +29 -3
  18. {wup-0.2.82 → wup-0.2.83}/wup.egg-info/PKG-INFO +9 -9
  19. {wup-0.2.82 → wup-0.2.83}/wup.egg-info/SOURCES.txt +2 -0
  20. {wup-0.2.82 → wup-0.2.83}/wup.egg-info/requires.txt +6 -2
  21. {wup-0.2.82 → wup-0.2.83}/LICENSE +0 -0
  22. {wup-0.2.82 → wup-0.2.83}/setup.cfg +0 -0
  23. {wup-0.2.82 → wup-0.2.83}/tests/test_aql.py +0 -0
  24. {wup-0.2.82 → wup-0.2.83}/tests/test_assistant.py +0 -0
  25. {wup-0.2.82 → wup-0.2.83}/tests/test_auto_detection.py +0 -0
  26. {wup-0.2.82 → wup-0.2.83}/tests/test_bootstrap.py +0 -0
  27. {wup-0.2.82 → wup-0.2.83}/tests/test_cli_bridge.py +0 -0
  28. {wup-0.2.82 → wup-0.2.83}/tests/test_cli_filtering.py +0 -0
  29. {wup-0.2.82 → wup-0.2.83}/tests/test_discovery_adapters.py +0 -0
  30. {wup-0.2.82 → wup-0.2.83}/tests/test_e2e.py +0 -0
  31. {wup-0.2.82 → wup-0.2.83}/tests/test_genericity.py +0 -0
  32. {wup-0.2.82 → wup-0.2.83}/tests/test_health_summary_passed.py +0 -0
  33. {wup-0.2.82 → wup-0.2.83}/tests/test_intent_monitor.py +0 -0
  34. {wup-0.2.82 → wup-0.2.83}/tests/test_monitoring_manifest.py +0 -0
  35. {wup-0.2.82 → wup-0.2.83}/tests/test_multi_project.py +0 -0
  36. {wup-0.2.82 → wup-0.2.83}/tests/test_oql.py +0 -0
  37. {wup-0.2.82 → wup-0.2.83}/tests/test_planfile_reporter_dedupe.py +0 -0
  38. {wup-0.2.82 → wup-0.2.83}/tests/test_probe_mutex.py +0 -0
  39. {wup-0.2.82 → wup-0.2.83}/tests/test_sync.py +0 -0
  40. {wup-0.2.82 → wup-0.2.83}/tests/test_testql_monitor.py +0 -0
  41. {wup-0.2.82 → wup-0.2.83}/tests/test_visual_diff_periodic_skip.py +0 -0
  42. {wup-0.2.82 → wup-0.2.83}/tests/test_visual_diff_progress.py +0 -0
  43. {wup-0.2.82 → wup-0.2.83}/tests/test_watch_exclude.py +0 -0
  44. {wup-0.2.82 → wup-0.2.83}/tests/test_watch_no_paths_exit.py +0 -0
  45. {wup-0.2.82 → wup-0.2.83}/tests/test_web_client.py +0 -0
  46. {wup-0.2.82 → wup-0.2.83}/tests/test_wup_generate.py +0 -0
  47. {wup-0.2.82 → wup-0.2.83}/wup/_ast_detector.py +0 -0
  48. {wup-0.2.82 → wup-0.2.83}/wup/_base_detector.py +0 -0
  49. {wup-0.2.82 → wup-0.2.83}/wup/_hash_detector.py +0 -0
  50. {wup-0.2.82 → wup-0.2.83}/wup/_yaml_detector.py +0 -0
  51. {wup-0.2.82 → wup-0.2.83}/wup/anomaly_detector.py +0 -0
  52. {wup-0.2.82 → wup-0.2.83}/wup/anomaly_models.py +0 -0
  53. {wup-0.2.82 → wup-0.2.83}/wup/aql.py +0 -0
  54. {wup-0.2.82 → wup-0.2.83}/wup/assistant.py +0 -0
  55. {wup-0.2.82 → wup-0.2.83}/wup/assistant_discovery.py +0 -0
  56. {wup-0.2.82 → wup-0.2.83}/wup/assistant_validator.py +0 -0
  57. {wup-0.2.82 → wup-0.2.83}/wup/bootstrap.py +0 -0
  58. {wup-0.2.82 → wup-0.2.83}/wup/bus.py +0 -0
  59. {wup-0.2.82 → wup-0.2.83}/wup/cli_bridge.py +0 -0
  60. {wup-0.2.82 → wup-0.2.83}/wup/cli_config_generator.py +0 -0
  61. {wup-0.2.82 → wup-0.2.83}/wup/cli_scanner.py +0 -0
  62. {wup-0.2.82 → wup-0.2.83}/wup/config.py +0 -0
  63. {wup-0.2.82 → wup-0.2.83}/wup/control.py +0 -0
  64. {wup-0.2.82 → wup-0.2.83}/wup/dependency_mapper.py +0 -0
  65. {wup-0.2.82 → wup-0.2.83}/wup/discovery.py +0 -0
  66. {wup-0.2.82 → wup-0.2.83}/wup/endpoints.py +0 -0
  67. {wup-0.2.82 → wup-0.2.83}/wup/file_watcher/events/file_events.py +0 -0
  68. {wup-0.2.82 → wup-0.2.83}/wup/generate.py +0 -0
  69. {wup-0.2.82 → wup-0.2.83}/wup/init_cli.py +0 -0
  70. {wup-0.2.82 → wup-0.2.83}/wup/intent_monitor.py +0 -0
  71. {wup-0.2.82 → wup-0.2.83}/wup/models/__init__.py +0 -0
  72. {wup-0.2.82 → wup-0.2.83}/wup/models/config.py +0 -0
  73. {wup-0.2.82 → wup-0.2.83}/wup/models/target.py +0 -0
  74. {wup-0.2.82 → wup-0.2.83}/wup/monitoring_manifest.py +0 -0
  75. {wup-0.2.82 → wup-0.2.83}/wup/multi.py +0 -0
  76. {wup-0.2.82 → wup-0.2.83}/wup/oql.py +0 -0
  77. {wup-0.2.82 → wup-0.2.83}/wup/paths.py +0 -0
  78. {wup-0.2.82 → wup-0.2.83}/wup/planfile_reporter.py +0 -0
  79. {wup-0.2.82 → wup-0.2.83}/wup/status_data.py +0 -0
  80. {wup-0.2.82 → wup-0.2.83}/wup/sync.py +0 -0
  81. {wup-0.2.82 → wup-0.2.83}/wup/testing/events/health_events.py +0 -0
  82. {wup-0.2.82 → wup-0.2.83}/wup/testing/events/test_results.py +0 -0
  83. {wup-0.2.82 → wup-0.2.83}/wup/testing/handlers/event_handlers.py +0 -0
  84. {wup-0.2.82 → wup-0.2.83}/wup/testing/handlers/health_handlers.py +0 -0
  85. {wup-0.2.82 → wup-0.2.83}/wup/testing/queries/health_queries.py +0 -0
  86. {wup-0.2.82 → wup-0.2.83}/wup/testql_cli_generator.py +0 -0
  87. {wup-0.2.82 → wup-0.2.83}/wup/testql_discovery.py +0 -0
  88. {wup-0.2.82 → wup-0.2.83}/wup/validate.py +0 -0
  89. {wup-0.2.82 → wup-0.2.83}/wup/visual_diff.py +0 -0
  90. {wup-0.2.82 → wup-0.2.83}/wup/web_client.py +0 -0
  91. {wup-0.2.82 → wup-0.2.83}/wup.egg-info/dependency_links.txt +0 -0
  92. {wup-0.2.82 → wup-0.2.83}/wup.egg-info/entry_points.txt +0 -0
  93. {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.82
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.82-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
51
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.59-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-57.8h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
50
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.83-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
51
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$6.83-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-63.8h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
52
52
 
53
- - 🤖 **LLM usage:** $7.5884 (114 commits)
54
- - 👤 **Human dev:** ~$5778 (57.8h @ $100/h, 30min dedup)
53
+ - 🤖 **LLM usage:** $6.8315 (120 commits)
54
+ - 👤 **Human dev:** ~$6379 (63.8h @ $100/h, 30min dedup)
55
55
 
56
- Generated on 2026-08-01 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.82-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
60
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.83-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.82-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.59-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-57.8h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.83-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$6.83-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-63.8h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $7.5884 (114 commits)
10
- - 👤 **Human dev:** ~$5778 (57.8h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $6.8315 (120 commits)
10
+ - 👤 **Human dev:** ~$6379 (63.8h @ $100/h, 30min dedup)
11
11
 
12
- Generated on 2026-08-01 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.82-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
16
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.83-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
17
17
 
18
18
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
19
19
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wup"
7
- version = "0.2.82"
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(str(config), project=str(tmp_path))
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(f"ENDPOINTS {scen} OUT {tmp_path / 'out.json'}")
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(f"INIT_CLI {tmp_path} OUT wup.yaml SCENARIOS testql-scenarios")
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(f"STATUS PROJECT {tmp_path} FILE wup.yaml")
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 test_quick_interrupt_does_not_create_failure_track():
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
- with pytest.raises(KeyboardInterrupt):
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.82"
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"),