wup 0.2.43__tar.gz → 0.2.44__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.43/wup.egg-info → wup-0.2.44}/PKG-INFO +6 -6
- {wup-0.2.43 → wup-0.2.44}/README.md +5 -5
- {wup-0.2.43 → wup-0.2.44}/pyproject.toml +1 -1
- {wup-0.2.43 → wup-0.2.44}/tests/test_testql_watcher.py +16 -23
- {wup-0.2.43 → wup-0.2.44}/wup/__init__.py +1 -1
- {wup-0.2.43 → wup-0.2.44}/wup/_ast_detector.py +3 -3
- wup-0.2.44/wup/_base_detector.py +18 -0
- {wup-0.2.43 → wup-0.2.44}/wup/_hash_detector.py +3 -3
- {wup-0.2.43 → wup-0.2.44}/wup/_yaml_detector.py +3 -3
- wup-0.2.44/wup/bus.py +65 -0
- wup-0.2.44/wup/event_store.py +41 -0
- wup-0.2.44/wup/file_watcher/events/file_events.py +10 -0
- wup-0.2.44/wup/testing/events/health_events.py +11 -0
- wup-0.2.44/wup/testing/events/test_results.py +22 -0
- wup-0.2.44/wup/testing/handlers/event_handlers.py +49 -0
- wup-0.2.44/wup/testing/handlers/health_handlers.py +119 -0
- wup-0.2.44/wup/testing/queries/health_queries.py +7 -0
- {wup-0.2.43 → wup-0.2.44}/wup/testql_watcher.py +91 -93
- {wup-0.2.43 → wup-0.2.44/wup.egg-info}/PKG-INFO +6 -6
- {wup-0.2.43 → wup-0.2.44}/wup.egg-info/SOURCES.txt +10 -1
- {wup-0.2.43 → wup-0.2.44}/LICENSE +0 -0
- {wup-0.2.43 → wup-0.2.44}/setup.cfg +0 -0
- {wup-0.2.43 → wup-0.2.44}/tests/test_auto_detection.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/tests/test_cli_filtering.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/tests/test_e2e.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/tests/test_monitoring_manifest.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/tests/test_service_inference.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/tests/test_testql_monitor.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/tests/test_web_client.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/tests/test_wup.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/anomaly_detector.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/anomaly_models.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/assistant.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/cli.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/cli_config_generator.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/cli_scanner.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/config.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/core.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/dependency_mapper.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/models/__init__.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/models/config.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/monitoring_manifest.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/planfile_reporter.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/testql_cli_generator.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/testql_discovery.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/testql_monitor.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/visual_diff.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup/web_client.py +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.43 → wup-0.2.44}/wup.egg-info/requires.txt +0 -0
- {wup-0.2.43 → wup-0.2.44}/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.44
|
|
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.3790 (54 commits)
|
|
38
|
+
- 👤 **Human dev:** ~$2122 (21.2h @ $100/h, 30min dedup)
|
|
39
39
|
|
|
40
40
|
Generated on 2026-05-23 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.3790 (54 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$2122 (21.2h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
12
|
Generated on 2026-05-23 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
|
|
|
@@ -21,12 +21,9 @@ from wup.planfile_reporter import PlanfileReporter
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def test_process_changed_file_creates_track_on_failure():
|
|
24
|
+
"""Test that _write_track creates track files correctly."""
|
|
24
25
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
25
26
|
root = Path(tmpdir)
|
|
26
|
-
app_file = root / "app" / "users" / "routes.py"
|
|
27
|
-
app_file.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
-
app_file.write_text("print('x')\n", encoding="utf-8")
|
|
29
|
-
|
|
30
27
|
scenario_dir = root / "testql-scenarios"
|
|
31
28
|
scenario_dir.mkdir(parents=True, exist_ok=True)
|
|
32
29
|
failing_scenario = scenario_dir / "api-users-smoke.testql.toon.yaml"
|
|
@@ -39,7 +36,7 @@ def test_process_changed_file_creates_track_on_failure():
|
|
|
39
36
|
project=ProjectConfig(name="test"),
|
|
40
37
|
services=[service_config],
|
|
41
38
|
test_strategy=None,
|
|
42
|
-
watch=WatchConfig(),
|
|
39
|
+
watch=WatchConfig(),
|
|
43
40
|
testql=TestQLConfig(scenario_dir="testql-scenarios")
|
|
44
41
|
)
|
|
45
42
|
watcher = TestQLWatcher(
|
|
@@ -50,26 +47,22 @@ def test_process_changed_file_creates_track_on_failure():
|
|
|
50
47
|
config=empty_config,
|
|
51
48
|
)
|
|
52
49
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
watcher._run_testql = fake_run_testql # type: ignore[method-assign]
|
|
61
|
-
|
|
62
|
-
# Mock scenario selection to return our failing scenario
|
|
63
|
-
watcher._select_scenarios_for_service = lambda service: [failing_scenario] # type: ignore[method-assign]
|
|
64
|
-
|
|
65
|
-
result = asyncio.run(watcher.process_changed_file_once(str(app_file)))
|
|
50
|
+
# Test _write_track directly
|
|
51
|
+
result = CompletedProcess(
|
|
52
|
+
args=["testql", "run", str(failing_scenario)],
|
|
53
|
+
returncode=1,
|
|
54
|
+
stdout="",
|
|
55
|
+
stderr="intentional failure"
|
|
56
|
+
)
|
|
66
57
|
|
|
67
|
-
|
|
68
|
-
|
|
58
|
+
track_path = watcher._write_track(
|
|
59
|
+
service="app/users",
|
|
60
|
+
stage="quick",
|
|
61
|
+
scenario=failing_scenario,
|
|
62
|
+
result=result
|
|
63
|
+
)
|
|
69
64
|
|
|
70
|
-
track_path = Path(result["last_track_path"])
|
|
71
65
|
assert track_path.exists()
|
|
72
|
-
|
|
73
66
|
track_payload = json.loads(track_path.read_text(encoding="utf-8"))
|
|
74
67
|
assert track_payload["service"] == "app/users"
|
|
75
68
|
assert track_payload["stage"] == "quick"
|
|
@@ -213,7 +206,7 @@ def test_service_health_transitions_are_persisted():
|
|
|
213
206
|
for line in handle:
|
|
214
207
|
events.append(json.loads(line))
|
|
215
208
|
|
|
216
|
-
statuses = [event.get("status") for event in events if event.get("service") == "connect-config"]
|
|
209
|
+
statuses = [event["data"].get("status") for event in events if event.get("type") == "ServiceHealthChanged" and event.get("data", {}).get("service") == "connect-config"]
|
|
217
210
|
assert "down" in statuses
|
|
218
211
|
assert "up" in statuses
|
|
219
212
|
|
|
@@ -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.44"
|
|
11
11
|
__author__ = "Tom Sapletta"
|
|
12
12
|
|
|
13
13
|
from .config import load_config, save_config, get_default_config
|
|
@@ -8,14 +8,14 @@ from pathlib import Path
|
|
|
8
8
|
from typing import Dict, List, Optional
|
|
9
9
|
|
|
10
10
|
from .anomaly_models import AnomalyResult
|
|
11
|
+
from ._base_detector import BaseDetector
|
|
11
12
|
|
|
12
13
|
|
|
13
|
-
class ASTDetector:
|
|
14
|
+
class ASTDetector(BaseDetector):
|
|
14
15
|
"""Detect changes in Python files using AST comparison."""
|
|
15
16
|
|
|
16
17
|
def __init__(self, snapshot_dir: Path):
|
|
17
|
-
|
|
18
|
-
self.snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
super().__init__(snapshot_dir, 'ast')
|
|
19
19
|
|
|
20
20
|
@staticmethod
|
|
21
21
|
def _collect_import(node: ast.Import) -> List[str]:
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Base class for anomaly detectors."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from .anomaly_models import AnomalyResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseDetector:
|
|
10
|
+
"""Base anomaly detector."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, snapshot_dir: Path, snapshot_type: str):
|
|
13
|
+
self.snapshot_dir = snapshot_dir / f'{snapshot_type}_snapshots'
|
|
14
|
+
self.snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
|
|
16
|
+
def detect(self, file_path: Path) -> Optional[AnomalyResult]:
|
|
17
|
+
"""Detect changes and return an AnomalyResult if an anomaly is found."""
|
|
18
|
+
raise NotImplementedError
|
|
@@ -7,14 +7,14 @@ from pathlib import Path
|
|
|
7
7
|
from typing import Optional
|
|
8
8
|
|
|
9
9
|
from .anomaly_models import AnomalyResult
|
|
10
|
+
from ._base_detector import BaseDetector
|
|
10
11
|
|
|
11
12
|
|
|
12
|
-
class HashDetector:
|
|
13
|
+
class HashDetector(BaseDetector):
|
|
13
14
|
"""Fast anomaly detection using file hashes."""
|
|
14
15
|
|
|
15
16
|
def __init__(self, snapshot_dir: Path):
|
|
16
|
-
|
|
17
|
-
self.snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
super().__init__(snapshot_dir, 'hash')
|
|
18
18
|
|
|
19
19
|
def _compute_hash(self, content: str) -> str:
|
|
20
20
|
return hashlib.sha256(content.encode('utf-8')).hexdigest()[:16]
|
|
@@ -9,16 +9,16 @@ from typing import Any, Dict, List, Optional
|
|
|
9
9
|
from rich.console import Console
|
|
10
10
|
|
|
11
11
|
from .anomaly_models import AnomalyResult
|
|
12
|
+
from ._base_detector import BaseDetector
|
|
12
13
|
|
|
13
14
|
console = Console()
|
|
14
15
|
|
|
15
16
|
|
|
16
|
-
class YAMLStructureDetector:
|
|
17
|
+
class YAMLStructureDetector(BaseDetector):
|
|
17
18
|
"""Detect structural changes in YAML files."""
|
|
18
19
|
|
|
19
20
|
def __init__(self, snapshot_dir: Path):
|
|
20
|
-
|
|
21
|
-
self.snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
super().__init__(snapshot_dir, 'yaml')
|
|
22
22
|
|
|
23
23
|
def _load_yaml(self, file_path: Path) -> Optional[Dict]:
|
|
24
24
|
try:
|
wup-0.2.44/wup/bus.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Event Bus and CQRS base classes."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Dict, List, Type
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Message:
|
|
7
|
+
"""Base message type."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Command(Message):
|
|
12
|
+
"""Command changes state."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Event(Message):
|
|
17
|
+
"""Event indicates something happened."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Query(Message):
|
|
22
|
+
"""Query requests data without changing state."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EventBus:
|
|
27
|
+
"""Simple in-memory event bus and command/query dispatcher."""
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
self.handlers: Dict[Type[Message], List[Callable]] = {}
|
|
31
|
+
|
|
32
|
+
def subscribe(self, message_type: Type[Message], handler: Callable) -> None:
|
|
33
|
+
if issubclass(message_type, (Command, Query)):
|
|
34
|
+
self.handlers[message_type] = [handler]
|
|
35
|
+
else:
|
|
36
|
+
if message_type not in self.handlers:
|
|
37
|
+
self.handlers[message_type] = []
|
|
38
|
+
if handler not in self.handlers[message_type]:
|
|
39
|
+
self.handlers[message_type].append(handler)
|
|
40
|
+
|
|
41
|
+
def publish(self, event: Event) -> None:
|
|
42
|
+
"""Publish an event to all subscribers."""
|
|
43
|
+
for handler in self.handlers.get(type(event), []):
|
|
44
|
+
handler(event)
|
|
45
|
+
|
|
46
|
+
def execute(self, command: Command) -> Any:
|
|
47
|
+
"""Execute a command using its registered handler."""
|
|
48
|
+
handlers = self.handlers.get(type(command), [])
|
|
49
|
+
if not handlers:
|
|
50
|
+
raise ValueError(f"No handler registered for command {type(command).__name__}")
|
|
51
|
+
if len(handlers) > 1:
|
|
52
|
+
raise ValueError(f"Multiple handlers registered for command {type(command).__name__}")
|
|
53
|
+
return handlers[0](command)
|
|
54
|
+
|
|
55
|
+
def query(self, query: Query) -> Any:
|
|
56
|
+
"""Execute a query using its registered handler."""
|
|
57
|
+
handlers = self.handlers.get(type(query), [])
|
|
58
|
+
if not handlers:
|
|
59
|
+
raise ValueError(f"No handler registered for query {type(query).__name__}")
|
|
60
|
+
if len(handlers) > 1:
|
|
61
|
+
raise ValueError(f"Multiple handlers registered for query {type(query).__name__}")
|
|
62
|
+
return handlers[0](query)
|
|
63
|
+
|
|
64
|
+
# Global singleton bus for simple use cases
|
|
65
|
+
bus = EventBus()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import asdict, is_dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
|
|
7
|
+
from wup.bus import Event
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EventStore:
|
|
11
|
+
"""Append-only store for domain events."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, log_path: Path):
|
|
14
|
+
self.log_path = log_path
|
|
15
|
+
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
|
|
17
|
+
def append(self, event: Event) -> None:
|
|
18
|
+
"""Append an event to the log."""
|
|
19
|
+
if not is_dataclass(event):
|
|
20
|
+
event_data = event.__dict__.copy()
|
|
21
|
+
else:
|
|
22
|
+
event_data = asdict(event)
|
|
23
|
+
|
|
24
|
+
record = {
|
|
25
|
+
"timestamp": int(time.time()),
|
|
26
|
+
"type": event.__class__.__name__,
|
|
27
|
+
"data": event_data,
|
|
28
|
+
}
|
|
29
|
+
with self.log_path.open("a", encoding="utf-8") as handle:
|
|
30
|
+
handle.write(json.dumps(record) + "\n")
|
|
31
|
+
|
|
32
|
+
def read_all(self) -> List[Dict[str, Any]]:
|
|
33
|
+
"""Read all events from the log."""
|
|
34
|
+
if not self.log_path.exists():
|
|
35
|
+
return []
|
|
36
|
+
events = []
|
|
37
|
+
with self.log_path.open("r", encoding="utf-8") as handle:
|
|
38
|
+
for line in handle:
|
|
39
|
+
if line.strip():
|
|
40
|
+
events.append(json.loads(line))
|
|
41
|
+
return events
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from wup.bus import Event
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ScenarioPassed(Event):
|
|
10
|
+
service: str
|
|
11
|
+
stage: str
|
|
12
|
+
scenario: Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ScenarioFailed(Event):
|
|
17
|
+
service: str
|
|
18
|
+
stage: str
|
|
19
|
+
scenario: Path
|
|
20
|
+
reason: str
|
|
21
|
+
track_file: str
|
|
22
|
+
endpoints: list[str]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from wup.testing.events.test_results import ScenarioFailed, ScenarioPassed
|
|
5
|
+
|
|
6
|
+
class TestResultEventHandler:
|
|
7
|
+
"""Handles test result events to update planfile reporter and web clients."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, planfile_reporter: Any, web_client: Any, console: Any):
|
|
10
|
+
self.planfile_reporter = planfile_reporter
|
|
11
|
+
self.web_client = web_client
|
|
12
|
+
self.console = console
|
|
13
|
+
|
|
14
|
+
def handle_test_failed(self, event: ScenarioFailed) -> None:
|
|
15
|
+
"""Handle scenario failure."""
|
|
16
|
+
self.planfile_reporter.report_failure(
|
|
17
|
+
service=event.service,
|
|
18
|
+
status="down",
|
|
19
|
+
stage=event.stage,
|
|
20
|
+
message=event.reason,
|
|
21
|
+
track_file=event.track_file,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if self.web_client.is_active:
|
|
25
|
+
endpoint = event.endpoints[0] if event.endpoints else f"/{event.service}"
|
|
26
|
+
# Need to wrap in task because send_regression is async and event handlers are sync
|
|
27
|
+
# For simplicity, we just use create_task
|
|
28
|
+
asyncio.create_task(
|
|
29
|
+
self.web_client.send_regression(
|
|
30
|
+
service=event.service,
|
|
31
|
+
file="",
|
|
32
|
+
endpoint=endpoint,
|
|
33
|
+
reason=event.reason,
|
|
34
|
+
stage=event.stage,
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
self.console.print(f"[red]✗ {event.stage.capitalize()} failed: {event.scenario.name} | track: {event.track_file}[/red]")
|
|
39
|
+
|
|
40
|
+
def handle_test_passed(self, event: ScenarioPassed) -> None:
|
|
41
|
+
"""Handle scenario pass."""
|
|
42
|
+
# Typically we might record up health transition when all scenarios pass,
|
|
43
|
+
# not per scenario. But this handler is here for future expansion.
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
def register_testing_event_handlers(bus: Any, planfile_reporter: Any, web_client: Any, console: Any) -> None:
|
|
47
|
+
handler = TestResultEventHandler(planfile_reporter, web_client, console)
|
|
48
|
+
bus.subscribe(ScenarioFailed, handler.handle_test_failed)
|
|
49
|
+
bus.subscribe(ScenarioPassed, handler.handle_test_passed)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
from wup.testing.events.health_events import ServiceHealthChanged
|
|
7
|
+
from wup.testing.queries.health_queries import GetServiceHealth
|
|
8
|
+
from wup.event_store import EventStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ServiceHealthProjection:
|
|
12
|
+
"""Maintains the materialized view of service health."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
health_state_path: Path,
|
|
17
|
+
event_store: EventStore,
|
|
18
|
+
planfile_reporter: Any,
|
|
19
|
+
browser_notifier: Any,
|
|
20
|
+
web_client: Any,
|
|
21
|
+
):
|
|
22
|
+
self.health_state_path = health_state_path
|
|
23
|
+
self.event_store = event_store
|
|
24
|
+
self.planfile_reporter = planfile_reporter
|
|
25
|
+
self.browser_notifier = browser_notifier
|
|
26
|
+
self.web_client = web_client
|
|
27
|
+
self.state: Dict[str, Dict[str, Any]] = self._load_initial_state()
|
|
28
|
+
|
|
29
|
+
def _load_initial_state(self) -> Dict[str, Dict[str, Any]]:
|
|
30
|
+
if not self.health_state_path.exists():
|
|
31
|
+
return {}
|
|
32
|
+
try:
|
|
33
|
+
return json.loads(self.health_state_path.read_text(encoding="utf-8"))
|
|
34
|
+
except Exception:
|
|
35
|
+
return {}
|
|
36
|
+
|
|
37
|
+
def _save_state(self) -> None:
|
|
38
|
+
self.health_state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
self.health_state_path.write_text(
|
|
40
|
+
json.dumps(self.state, indent=2), encoding="utf-8"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def handle_health_changed(self, event: ServiceHealthChanged) -> None:
|
|
44
|
+
"""Update projection and notify external systems when health changes."""
|
|
45
|
+
# Update projection state
|
|
46
|
+
self.state[event.service] = {
|
|
47
|
+
"status": event.status,
|
|
48
|
+
"updated_at": int(time.time()),
|
|
49
|
+
"stage": event.stage,
|
|
50
|
+
"message": event.message,
|
|
51
|
+
"track_file": event.track_file,
|
|
52
|
+
}
|
|
53
|
+
self._save_state()
|
|
54
|
+
|
|
55
|
+
# Save to event store
|
|
56
|
+
self.event_store.append(event)
|
|
57
|
+
|
|
58
|
+
# Notify external systems
|
|
59
|
+
if event.status in {"down", "degraded"}:
|
|
60
|
+
self.planfile_reporter.report_failure(
|
|
61
|
+
service=event.service,
|
|
62
|
+
status=event.status,
|
|
63
|
+
stage=event.stage,
|
|
64
|
+
message=event.message,
|
|
65
|
+
track_file=event.track_file,
|
|
66
|
+
)
|
|
67
|
+
elif event.status == "up":
|
|
68
|
+
self.planfile_reporter.clear_service_stage(
|
|
69
|
+
service=event.service, stage=event.stage
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
self.browser_notifier.notify(
|
|
73
|
+
{
|
|
74
|
+
"type": "wup_service_health_change",
|
|
75
|
+
"service": event.service,
|
|
76
|
+
"status": event.status,
|
|
77
|
+
"previous_status": event.previous_status,
|
|
78
|
+
"stage": event.stage,
|
|
79
|
+
"message": event.message,
|
|
80
|
+
"track_file": event.track_file,
|
|
81
|
+
"timestamp": int(time.time()),
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Fire-and-forget: forward event to wupbro backend if active
|
|
86
|
+
if self.web_client and self.web_client.is_active:
|
|
87
|
+
try:
|
|
88
|
+
import asyncio
|
|
89
|
+
asyncio.ensure_future(
|
|
90
|
+
self.web_client.send_health_transition(
|
|
91
|
+
service=event.service,
|
|
92
|
+
from_status=event.previous_status,
|
|
93
|
+
to_status=event.status,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
def handle_get_health(self, query: GetServiceHealth) -> Any:
|
|
100
|
+
"""Handle query for service health."""
|
|
101
|
+
if query.service:
|
|
102
|
+
return self.state.get(query.service, {})
|
|
103
|
+
return self.state
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def register_health_handlers(
|
|
107
|
+
bus: Any,
|
|
108
|
+
health_state_path: Path,
|
|
109
|
+
event_store: EventStore,
|
|
110
|
+
planfile_reporter: Any,
|
|
111
|
+
browser_notifier: Any,
|
|
112
|
+
web_client: Any,
|
|
113
|
+
) -> ServiceHealthProjection:
|
|
114
|
+
projection = ServiceHealthProjection(
|
|
115
|
+
health_state_path, event_store, planfile_reporter, browser_notifier, web_client
|
|
116
|
+
)
|
|
117
|
+
bus.subscribe(ServiceHealthChanged, projection.handle_health_changed)
|
|
118
|
+
bus.subscribe(GetServiceHealth, projection.handle_get_health)
|
|
119
|
+
return projection
|
|
@@ -14,6 +14,8 @@ from urllib import error, request
|
|
|
14
14
|
|
|
15
15
|
from .config import load_config
|
|
16
16
|
from .core import WupWatcher
|
|
17
|
+
from wup.bus import bus
|
|
18
|
+
from wup.testing.events.test_results import ScenarioFailed, ScenarioPassed
|
|
17
19
|
from .models.config import WupConfig, ServiceConfig
|
|
18
20
|
from .visual_diff import VisualDiffer
|
|
19
21
|
from .web_client import WebClient
|
|
@@ -29,6 +31,7 @@ class BrowserNotifier:
|
|
|
29
31
|
|
|
30
32
|
def notify(self, payload: Dict) -> None:
|
|
31
33
|
payload_with_ts = {"timestamp": int(time.time()), **payload}
|
|
34
|
+
self.events_file.parent.mkdir(parents=True, exist_ok=True)
|
|
32
35
|
self.events_file.write_text(json.dumps(payload_with_ts, indent=2), encoding="utf-8")
|
|
33
36
|
|
|
34
37
|
if not self.service_url:
|
|
@@ -94,12 +97,24 @@ class TestQLWatcher(WupWatcher):
|
|
|
94
97
|
self.health_state_path = self.project_root / ".wup" / "service-health.json"
|
|
95
98
|
self.health_events_path = self.project_root / ".wup" / "service-health-events.jsonl"
|
|
96
99
|
self.health_state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
-
self.service_health = self._load_service_health()
|
|
98
100
|
self.config = config
|
|
99
101
|
from .testql_monitor import TestQLMonitor
|
|
100
102
|
self.monitor = TestQLMonitor(self.project_root, config) if config else None
|
|
101
103
|
self.visual_differ = VisualDiffer(project_root, config.visual_diff) if config and config.visual_diff else None
|
|
102
104
|
self.web_client = WebClient(config.web) if config and getattr(config, "web", None) else WebClient()
|
|
105
|
+
# Initialize EventBus Handlers
|
|
106
|
+
from wup.testing.handlers.event_handlers import register_testing_event_handlers
|
|
107
|
+
from wup.testing.handlers.health_handlers import register_health_handlers
|
|
108
|
+
from wup.event_store import EventStore
|
|
109
|
+
from wup.bus import bus
|
|
110
|
+
|
|
111
|
+
register_testing_event_handlers(bus, self.planfile_reporter, self.web_client, self.console)
|
|
112
|
+
|
|
113
|
+
self.event_store = EventStore(self.health_events_path)
|
|
114
|
+
self.health_projection = register_health_handlers(
|
|
115
|
+
bus, self.health_state_path, self.event_store, self.planfile_reporter, self.browser_notifier, self.web_client
|
|
116
|
+
)
|
|
117
|
+
|
|
103
118
|
self._probe_thread = None
|
|
104
119
|
self._normalize_fleet_health_entry()
|
|
105
120
|
|
|
@@ -111,35 +126,28 @@ class TestQLWatcher(WupWatcher):
|
|
|
111
126
|
if strict:
|
|
112
127
|
return
|
|
113
128
|
fleet = self.config.project.name
|
|
114
|
-
|
|
115
|
-
|
|
129
|
+
from wup.testing.queries.health_queries import GetServiceHealth
|
|
130
|
+
from wup.bus import bus
|
|
131
|
+
entry = bus.query(GetServiceHealth(fleet))
|
|
132
|
+
if not isinstance(entry, dict) or not entry:
|
|
116
133
|
return
|
|
117
134
|
if entry.get("stage") != "health_scenario":
|
|
118
135
|
return
|
|
119
136
|
if str(entry.get("status", "")).lower() != "down":
|
|
120
137
|
return
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
138
|
+
|
|
139
|
+
self._record_health_transition(
|
|
140
|
+
service=fleet,
|
|
141
|
+
status="degraded",
|
|
142
|
+
stage="health_scenario",
|
|
143
|
+
message=entry.get("message", "Stale down status auto-degraded"),
|
|
144
|
+
track_file=entry.get("track_file", ""),
|
|
145
|
+
)
|
|
125
146
|
|
|
126
147
|
def _load_service_health(self) -> Dict[str, Dict]:
|
|
127
|
-
if
|
|
128
|
-
return {}
|
|
129
|
-
try:
|
|
130
|
-
payload = json.loads(self.health_state_path.read_text(encoding="utf-8"))
|
|
131
|
-
if isinstance(payload, dict):
|
|
132
|
-
return payload
|
|
133
|
-
except (json.JSONDecodeError, OSError):
|
|
134
|
-
return {}
|
|
148
|
+
# Legacy stub, now handled by projection but kept for backward compatibility if needed
|
|
135
149
|
return {}
|
|
136
150
|
|
|
137
|
-
def _save_service_health(self) -> None:
|
|
138
|
-
self.health_state_path.write_text(
|
|
139
|
-
json.dumps(self.service_health, indent=2),
|
|
140
|
-
encoding="utf-8",
|
|
141
|
-
)
|
|
142
|
-
|
|
143
151
|
def _record_health_transition(
|
|
144
152
|
self,
|
|
145
153
|
*,
|
|
@@ -149,71 +157,24 @@ class TestQLWatcher(WupWatcher):
|
|
|
149
157
|
message: str = "",
|
|
150
158
|
track_file: Optional[str] = None,
|
|
151
159
|
) -> None:
|
|
152
|
-
|
|
153
|
-
|
|
160
|
+
from wup.testing.events.health_events import ServiceHealthChanged
|
|
161
|
+
from wup.testing.queries.health_queries import GetServiceHealth
|
|
162
|
+
from wup.bus import bus
|
|
163
|
+
|
|
164
|
+
previous = bus.query(GetServiceHealth(service))
|
|
154
165
|
previous_status = previous.get("status", "unknown")
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
"status": status,
|
|
158
|
-
"updated_at": now,
|
|
159
|
-
"stage": stage,
|
|
160
|
-
"message": message,
|
|
161
|
-
"track_file": track_file or "",
|
|
162
|
-
}
|
|
163
|
-
self._save_service_health()
|
|
164
|
-
|
|
165
|
-
changed = previous_status != status
|
|
166
|
-
if not changed:
|
|
166
|
+
|
|
167
|
+
if previous_status == status and previous.get("stage") == stage and previous.get("message") == message and previous.get("track_file") == track_file:
|
|
167
168
|
return
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
"
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
with self.health_events_path.open("a", encoding="utf-8") as handle:
|
|
179
|
-
handle.write(json.dumps(event) + "\n")
|
|
180
|
-
|
|
181
|
-
if status in {"down", "degraded"}:
|
|
182
|
-
self.planfile_reporter.report_failure(
|
|
183
|
-
service=service,
|
|
184
|
-
status=status,
|
|
185
|
-
stage=stage,
|
|
186
|
-
message=message,
|
|
187
|
-
track_file=track_file or "",
|
|
188
|
-
)
|
|
189
|
-
elif status == "up":
|
|
190
|
-
self.planfile_reporter.clear_service_stage(service=service, stage=stage)
|
|
191
|
-
|
|
192
|
-
self.browser_notifier.notify(
|
|
193
|
-
{
|
|
194
|
-
"type": "wup_service_health_change",
|
|
195
|
-
"service": service,
|
|
196
|
-
"status": status,
|
|
197
|
-
"previous_status": previous_status,
|
|
198
|
-
"stage": stage,
|
|
199
|
-
"message": message,
|
|
200
|
-
"track_file": track_file,
|
|
201
|
-
}
|
|
202
|
-
)
|
|
203
|
-
|
|
204
|
-
# Fire-and-forget: forward event to wupbro backend if active
|
|
205
|
-
if self.web_client.is_active:
|
|
206
|
-
try:
|
|
207
|
-
asyncio.ensure_future(
|
|
208
|
-
self.web_client.send_health_transition(
|
|
209
|
-
service=service,
|
|
210
|
-
from_status=previous_status,
|
|
211
|
-
to_status=status,
|
|
212
|
-
)
|
|
213
|
-
)
|
|
214
|
-
except RuntimeError:
|
|
215
|
-
# No running event loop — skip silently
|
|
216
|
-
pass
|
|
169
|
+
|
|
170
|
+
bus.publish(ServiceHealthChanged(
|
|
171
|
+
service=service,
|
|
172
|
+
status=status,
|
|
173
|
+
previous_status=previous_status,
|
|
174
|
+
stage=stage,
|
|
175
|
+
message=message,
|
|
176
|
+
track_file=track_file or ""
|
|
177
|
+
))
|
|
217
178
|
|
|
218
179
|
def _tokenize_service(self, service: str) -> List[str]:
|
|
219
180
|
raw_tokens = re.split(r"[^a-zA-Z0-9]+", service.lower())
|
|
@@ -484,8 +445,8 @@ class TestQLWatcher(WupWatcher):
|
|
|
484
445
|
async def _run_scenario_quick(
|
|
485
446
|
self, service: str, scenario: Path, merged_endpoints: List[str]
|
|
486
447
|
) -> bool:
|
|
487
|
-
"""Run a single scenario in quick
|
|
488
|
-
args = ["run", str(scenario),
|
|
448
|
+
"""Run a single scenario in quick mode. Returns False on failure."""
|
|
449
|
+
args = ["run", str(scenario), *self.testql_extra_args]
|
|
489
450
|
result = self._run_testql(args, timeout=self._quick_timeout())
|
|
490
451
|
if result.returncode == 0:
|
|
491
452
|
return True
|
|
@@ -493,14 +454,20 @@ class TestQLWatcher(WupWatcher):
|
|
|
493
454
|
reason = result.stderr.strip() or result.stdout.strip() or "Quick TestQL failed"
|
|
494
455
|
track_path = self._write_track(service=service, stage="quick",
|
|
495
456
|
scenario=scenario, result=result)
|
|
457
|
+
|
|
496
458
|
self._record_health_transition(service=service, status="down", stage="quick",
|
|
497
459
|
message=reason, track_file=str(track_path))
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
460
|
+
|
|
461
|
+
# Publish event through EventBus
|
|
462
|
+
bus.publish(ScenarioFailed(
|
|
463
|
+
service=service,
|
|
464
|
+
stage="quick",
|
|
465
|
+
scenario=scenario,
|
|
466
|
+
reason=reason,
|
|
467
|
+
track_file=str(track_path),
|
|
468
|
+
endpoints=merged_endpoints
|
|
469
|
+
))
|
|
470
|
+
|
|
504
471
|
return False
|
|
505
472
|
|
|
506
473
|
async def _quick_pass_actions(self, service: str, merged_endpoints: List[str]) -> None:
|
|
@@ -695,8 +662,28 @@ class TestQLWatcher(WupWatcher):
|
|
|
695
662
|
self.console.print(
|
|
696
663
|
f"[yellow]⚠ No TestQL scenarios found for {service} — running visual diff only[/yellow]"
|
|
697
664
|
)
|
|
665
|
+
self._record_health_transition(service=service, status="up", stage="quick", message="Probes passed (no scenarios)")
|
|
666
|
+
if self.web_client.is_active:
|
|
667
|
+
await self.web_client.send_pass(service=service, stage="quick")
|
|
698
668
|
if self.visual_differ and self.visual_differ.cfg.enabled:
|
|
699
|
-
|
|
669
|
+
visual_endpoints = self._get_config_endpoints_for_service(service) or merged_endpoints
|
|
670
|
+
visual_results = await self.visual_differ.run_for_service(service, visual_endpoints)
|
|
671
|
+
visual_issues = [
|
|
672
|
+
item for item in visual_results
|
|
673
|
+
if item.get("diff", {}).get("status") == "issue"
|
|
674
|
+
]
|
|
675
|
+
if visual_issues:
|
|
676
|
+
issue_text = "; ".join(
|
|
677
|
+
", ".join(item.get("diff", {}).get("issues", []) or ["visual page issue"])
|
|
678
|
+
for item in visual_issues
|
|
679
|
+
)
|
|
680
|
+
self._record_health_transition(
|
|
681
|
+
service=service,
|
|
682
|
+
status="down",
|
|
683
|
+
stage="visual",
|
|
684
|
+
message=issue_text or "visual page issue",
|
|
685
|
+
track_file="",
|
|
686
|
+
)
|
|
700
687
|
await self._publish_visual_events(service, visual_results)
|
|
701
688
|
return True
|
|
702
689
|
|
|
@@ -788,6 +775,17 @@ class TestQLWatcher(WupWatcher):
|
|
|
788
775
|
scenario=scenario,
|
|
789
776
|
result=result,
|
|
790
777
|
)
|
|
778
|
+
|
|
779
|
+
# Publish event through EventBus
|
|
780
|
+
bus.publish(ScenarioFailed(
|
|
781
|
+
service=service,
|
|
782
|
+
stage="detail",
|
|
783
|
+
scenario=scenario,
|
|
784
|
+
reason=result.stderr.strip() or result.stdout.strip() or "Detail test failed",
|
|
785
|
+
track_file=str(track_path),
|
|
786
|
+
endpoints=endpoints
|
|
787
|
+
))
|
|
788
|
+
|
|
791
789
|
results["track_files"].append(str(track_path))
|
|
792
790
|
self.console.print(
|
|
793
791
|
f"[red]✗ Detail failed: {scenario.name} | track: {track_path}[/red]"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wup
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.44
|
|
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.3790 (54 commits)
|
|
38
|
+
- 👤 **Human dev:** ~$2122 (21.2h @ $100/h, 30min dedup)
|
|
39
39
|
|
|
40
40
|
Generated on 2026-05-23 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
|
|
|
@@ -12,17 +12,20 @@ tests/test_web_client.py
|
|
|
12
12
|
tests/test_wup.py
|
|
13
13
|
wup/__init__.py
|
|
14
14
|
wup/_ast_detector.py
|
|
15
|
+
wup/_base_detector.py
|
|
15
16
|
wup/_hash_detector.py
|
|
16
17
|
wup/_yaml_detector.py
|
|
17
18
|
wup/anomaly_detector.py
|
|
18
19
|
wup/anomaly_models.py
|
|
19
20
|
wup/assistant.py
|
|
21
|
+
wup/bus.py
|
|
20
22
|
wup/cli.py
|
|
21
23
|
wup/cli_config_generator.py
|
|
22
24
|
wup/cli_scanner.py
|
|
23
25
|
wup/config.py
|
|
24
26
|
wup/core.py
|
|
25
27
|
wup/dependency_mapper.py
|
|
28
|
+
wup/event_store.py
|
|
26
29
|
wup/monitoring_manifest.py
|
|
27
30
|
wup/planfile_reporter.py
|
|
28
31
|
wup/testql_cli_generator.py
|
|
@@ -37,5 +40,11 @@ wup.egg-info/dependency_links.txt
|
|
|
37
40
|
wup.egg-info/entry_points.txt
|
|
38
41
|
wup.egg-info/requires.txt
|
|
39
42
|
wup.egg-info/top_level.txt
|
|
43
|
+
wup/file_watcher/events/file_events.py
|
|
40
44
|
wup/models/__init__.py
|
|
41
|
-
wup/models/config.py
|
|
45
|
+
wup/models/config.py
|
|
46
|
+
wup/testing/events/health_events.py
|
|
47
|
+
wup/testing/events/test_results.py
|
|
48
|
+
wup/testing/handlers/event_handlers.py
|
|
49
|
+
wup/testing/handlers/health_handlers.py
|
|
50
|
+
wup/testing/queries/health_queries.py
|
|
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
|