wup 0.2.32__tar.gz → 0.2.33__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.32/wup.egg-info → wup-0.2.33}/PKG-INFO +5 -5
- {wup-0.2.32 → wup-0.2.33}/README.md +4 -4
- {wup-0.2.32 → wup-0.2.33}/pyproject.toml +1 -1
- {wup-0.2.32 → wup-0.2.33}/tests/test_wup.py +46 -0
- {wup-0.2.32 → wup-0.2.33}/wup/__init__.py +1 -1
- {wup-0.2.32 → wup-0.2.33}/wup/cli.py +115 -53
- {wup-0.2.32 → wup-0.2.33}/wup/core.py +29 -14
- {wup-0.2.32 → wup-0.2.33}/wup/monitoring_manifest.py +93 -59
- {wup-0.2.32 → wup-0.2.33}/wup/testql_monitor.py +126 -67
- {wup-0.2.32 → wup-0.2.33}/wup/testql_watcher.py +38 -19
- {wup-0.2.32 → wup-0.2.33}/wup/visual_diff.py +71 -48
- {wup-0.2.32 → wup-0.2.33/wup.egg-info}/PKG-INFO +5 -5
- {wup-0.2.32 → wup-0.2.33}/LICENSE +0 -0
- {wup-0.2.32 → wup-0.2.33}/setup.cfg +0 -0
- {wup-0.2.32 → wup-0.2.33}/tests/test_e2e.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/tests/test_monitoring_manifest.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/tests/test_testql_monitor.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/tests/test_testql_watcher.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/tests/test_web_client.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/wup/_ast_detector.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/wup/_hash_detector.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/wup/_yaml_detector.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/wup/anomaly_detector.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/wup/anomaly_models.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/wup/assistant.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/wup/config.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/wup/dependency_mapper.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/wup/models/__init__.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/wup/models/config.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/wup/testql_discovery.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/wup/web_client.py +0 -0
- {wup-0.2.32 → wup-0.2.33}/wup.egg-info/SOURCES.txt +0 -0
- {wup-0.2.32 → wup-0.2.33}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.32 → wup-0.2.33}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.32 → wup-0.2.33}/wup.egg-info/requires.txt +0 -0
- {wup-0.2.32 → wup-0.2.33}/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.33
|
|
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:** $2.
|
|
37
|
+
- 🤖 **LLM usage:** $2.5582 (43 commits)
|
|
38
38
|
- 👤 **Human dev:** ~$1832 (18.3h @ $100/h, 30min dedup)
|
|
39
39
|
|
|
40
40
|
Generated on 2026-05-21 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:** $2.
|
|
9
|
+
- 🤖 **LLM usage:** $2.5582 (43 commits)
|
|
10
10
|
- 👤 **Human dev:** ~$1832 (18.3h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
12
|
Generated on 2026-05-21 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
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Tests for WUP (What's Up) - Intelligent file watcher for regression testing."""
|
|
2
2
|
|
|
3
|
+
import errno
|
|
3
4
|
import json
|
|
4
5
|
import tempfile
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
|
|
7
9
|
import pytest
|
|
@@ -609,6 +611,50 @@ class TestWupWatcher:
|
|
|
609
611
|
|
|
610
612
|
# No filtering should occur
|
|
611
613
|
|
|
614
|
+
def test_create_and_start_observer_fallback_on_enospc(self):
|
|
615
|
+
"""Fallback to PollingObserver when inotify watch limit is reached."""
|
|
616
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
617
|
+
watcher = WupWatcher(tmpdir)
|
|
618
|
+
event_handler = MagicMock()
|
|
619
|
+
fake_observer = MagicMock()
|
|
620
|
+
fake_observer.start.side_effect = OSError(errno.ENOSPC, "inotify watch limit reached")
|
|
621
|
+
|
|
622
|
+
with patch("wup.core.Observer", return_value=fake_observer):
|
|
623
|
+
with patch("wup.core.PollingObserver") as mock_polling:
|
|
624
|
+
fake_polling = MagicMock()
|
|
625
|
+
mock_polling.return_value = fake_polling
|
|
626
|
+
result = watcher._create_and_start_observer(event_handler, [tmpdir])
|
|
627
|
+
assert result is fake_polling
|
|
628
|
+
fake_polling.start.assert_called_once()
|
|
629
|
+
|
|
630
|
+
def test_create_and_start_observer_fallback_on_emfile(self):
|
|
631
|
+
"""Fallback to PollingObserver when inotify instance limit is reached."""
|
|
632
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
633
|
+
watcher = WupWatcher(tmpdir)
|
|
634
|
+
event_handler = MagicMock()
|
|
635
|
+
fake_observer = MagicMock()
|
|
636
|
+
fake_observer.start.side_effect = OSError(errno.EMFILE, "inotify instance limit reached")
|
|
637
|
+
|
|
638
|
+
with patch("wup.core.Observer", return_value=fake_observer):
|
|
639
|
+
with patch("wup.core.PollingObserver") as mock_polling:
|
|
640
|
+
fake_polling = MagicMock()
|
|
641
|
+
mock_polling.return_value = fake_polling
|
|
642
|
+
result = watcher._create_and_start_observer(event_handler, [tmpdir])
|
|
643
|
+
assert result is fake_polling
|
|
644
|
+
fake_polling.start.assert_called_once()
|
|
645
|
+
|
|
646
|
+
def test_create_and_start_observer_reraises_other_oserror(self):
|
|
647
|
+
"""Re-raise OSError that is not ENOSPC or EMFILE."""
|
|
648
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
649
|
+
watcher = WupWatcher(tmpdir)
|
|
650
|
+
event_handler = MagicMock()
|
|
651
|
+
fake_observer = MagicMock()
|
|
652
|
+
fake_observer.start.side_effect = OSError(errno.EACCES, "permission denied")
|
|
653
|
+
|
|
654
|
+
with patch("wup.core.Observer", return_value=fake_observer):
|
|
655
|
+
with pytest.raises(OSError, match="permission denied"):
|
|
656
|
+
watcher._create_and_start_observer(event_handler, [tmpdir])
|
|
657
|
+
|
|
612
658
|
|
|
613
659
|
class TestIntegrationWorkflow:
|
|
614
660
|
"""Integration tests for complete workflows."""
|
|
@@ -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.33"
|
|
11
11
|
__author__ = "Tom Sapletta"
|
|
12
12
|
|
|
13
13
|
from .config import load_config, save_config, get_default_config
|
|
@@ -23,6 +23,100 @@ app = typer.Typer(
|
|
|
23
23
|
console = Console()
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def _load_watch_config(
|
|
27
|
+
project_path: Path,
|
|
28
|
+
config_path: Optional[Path],
|
|
29
|
+
probe_interval: Optional[int],
|
|
30
|
+
mode: str,
|
|
31
|
+
) -> WupConfig:
|
|
32
|
+
"""Load wup.yaml config and apply CLI probe_interval override."""
|
|
33
|
+
wup_config = load_config(project_path, config_path)
|
|
34
|
+
if probe_interval is not None:
|
|
35
|
+
wup_config.testql.probe_interval_s = int(probe_interval)
|
|
36
|
+
elif mode.lower() == "testql" and not wup_config.testql.probe_interval_s:
|
|
37
|
+
wup_config.testql.probe_interval_s = 60
|
|
38
|
+
return wup_config
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _print_watch_header(
|
|
42
|
+
wup_config: WupConfig,
|
|
43
|
+
cpu_throttle: float,
|
|
44
|
+
debounce: int,
|
|
45
|
+
cooldown: int,
|
|
46
|
+
config_path: Optional[Path],
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Print watcher startup banner."""
|
|
49
|
+
console.print(f"[bold cyan]🚀 WUP Watcher[/bold cyan]")
|
|
50
|
+
console.print(f"[dim]Project: {wup_config.project.name}[/dim]")
|
|
51
|
+
console.print(f"[dim]Description: {wup_config.project.description}[/dim]")
|
|
52
|
+
console.print(f"[dim]CPU Throttle: {cpu_throttle * 100}%[/dim]")
|
|
53
|
+
console.print(f"[dim]Debounce: {debounce}s[/dim]")
|
|
54
|
+
console.print(f"[dim]Cooldown: {cooldown}s[/dim]")
|
|
55
|
+
if wup_config.testql.probe_interval_s:
|
|
56
|
+
console.print(f"[dim]Live probes: every {wup_config.testql.probe_interval_s}s[/dim]")
|
|
57
|
+
console.print(f"[dim]Config: {config_path or 'auto-detected'}[/dim]")
|
|
58
|
+
console.print()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _refresh_monitoring_manifest(
|
|
62
|
+
project_path: Path,
|
|
63
|
+
wup_config: WupConfig,
|
|
64
|
+
cfg_path: Optional[Path],
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Rebuild and patch monitoring manifest into wup.yaml when possible."""
|
|
67
|
+
if not cfg_path:
|
|
68
|
+
return
|
|
69
|
+
from .monitoring_manifest import build_monitoring_manifest, patch_wup_yaml_monitoring
|
|
70
|
+
try:
|
|
71
|
+
manifest = build_monitoring_manifest(project_path, wup_config)
|
|
72
|
+
patch_wup_yaml_monitoring(cfg_path, manifest)
|
|
73
|
+
console.print("[dim]Refreshed monitoring manifest in wup.yaml[/dim]")
|
|
74
|
+
except OSError as exc:
|
|
75
|
+
console.print(f"[yellow]Could not refresh monitoring manifest: {exc}[/yellow]")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _create_watcher(
|
|
79
|
+
mode: str,
|
|
80
|
+
project_path: Path,
|
|
81
|
+
deps_file: str,
|
|
82
|
+
cpu_throttle: float,
|
|
83
|
+
debounce: int,
|
|
84
|
+
cooldown: int,
|
|
85
|
+
scenarios_dir: Optional[str],
|
|
86
|
+
testql_bin: str,
|
|
87
|
+
browser_service_url: Optional[str],
|
|
88
|
+
track_dir: str,
|
|
89
|
+
quick_limit: int,
|
|
90
|
+
config: WupConfig,
|
|
91
|
+
) -> WupWatcher:
|
|
92
|
+
"""Instantiate the correct watcher class for the chosen mode."""
|
|
93
|
+
if mode.lower() == "testql":
|
|
94
|
+
watcher = TestQLWatcher(
|
|
95
|
+
project_root=str(project_path),
|
|
96
|
+
deps_file=deps_file,
|
|
97
|
+
cpu_throttle=cpu_throttle,
|
|
98
|
+
debounce_seconds=debounce,
|
|
99
|
+
test_cooldown_seconds=cooldown,
|
|
100
|
+
scenarios_dir=scenarios_dir,
|
|
101
|
+
testql_bin=testql_bin,
|
|
102
|
+
browser_service_url=browser_service_url,
|
|
103
|
+
track_dir=track_dir,
|
|
104
|
+
quick_limit=quick_limit,
|
|
105
|
+
config=config,
|
|
106
|
+
)
|
|
107
|
+
console.print("[green]TestQL mode enabled[/green]")
|
|
108
|
+
return watcher
|
|
109
|
+
|
|
110
|
+
return WupWatcher(
|
|
111
|
+
project_root=str(project_path),
|
|
112
|
+
deps_file=deps_file,
|
|
113
|
+
cpu_throttle=cpu_throttle,
|
|
114
|
+
debounce_seconds=debounce,
|
|
115
|
+
test_cooldown_seconds=cooldown,
|
|
116
|
+
config=config,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
26
120
|
@app.command()
|
|
27
121
|
def watch(
|
|
28
122
|
project: str = typer.Argument(".", help="Path to the project root directory"),
|
|
@@ -60,67 +154,35 @@ def watch(
|
|
|
60
154
|
``--mode default`` for the legacy HTTP-only watcher without TestQL.
|
|
61
155
|
"""
|
|
62
156
|
project_path = Path(project).resolve()
|
|
63
|
-
|
|
157
|
+
|
|
64
158
|
if not project_path.exists():
|
|
65
159
|
console.print(f"[red]Error: Project path '{project}' does not exist[/red]")
|
|
66
160
|
raise typer.Exit(1)
|
|
67
|
-
|
|
68
|
-
# Load configuration
|
|
69
|
-
config_path = Path(config) if config else None
|
|
70
|
-
wup_config = load_config(project_path, config_path)
|
|
71
|
-
if probe_interval is not None:
|
|
72
|
-
wup_config.testql.probe_interval_s = int(probe_interval)
|
|
73
|
-
elif mode.lower() == "testql" and not wup_config.testql.probe_interval_s:
|
|
74
|
-
wup_config.testql.probe_interval_s = 60
|
|
75
161
|
|
|
162
|
+
config_path = Path(config) if config else None
|
|
163
|
+
wup_config = _load_watch_config(project_path, config_path, probe_interval, mode)
|
|
76
164
|
effective_scenarios_dir = scenarios_dir or wup_config.testql.scenario_dir
|
|
77
165
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
console.print(f"[dim]Description: {wup_config.project.description}[/dim]")
|
|
81
|
-
console.print(f"[dim]CPU Throttle: {cpu_throttle * 100}%[/dim]")
|
|
82
|
-
console.print(f"[dim]Debounce: {debounce}s[/dim]")
|
|
83
|
-
console.print(f"[dim]Cooldown: {cooldown}s[/dim]")
|
|
84
|
-
if wup_config.testql.probe_interval_s:
|
|
85
|
-
console.print(f"[dim]Live probes: every {wup_config.testql.probe_interval_s}s[/dim]")
|
|
86
|
-
console.print(f"[dim]Config: {config_path or 'auto-detected'}[/dim]")
|
|
87
|
-
console.print()
|
|
88
|
-
|
|
166
|
+
_print_watch_header(wup_config, cpu_throttle, debounce, cooldown, config_path)
|
|
167
|
+
|
|
89
168
|
cfg_path = config_path if config_path and config_path.exists() else find_config_file(project_path)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
169
|
+
_refresh_monitoring_manifest(project_path, wup_config, cfg_path)
|
|
170
|
+
|
|
171
|
+
watcher = _create_watcher(
|
|
172
|
+
mode=mode,
|
|
173
|
+
project_path=project_path,
|
|
174
|
+
deps_file=deps_file,
|
|
175
|
+
cpu_throttle=cpu_throttle,
|
|
176
|
+
debounce=debounce,
|
|
177
|
+
cooldown=cooldown,
|
|
178
|
+
scenarios_dir=effective_scenarios_dir,
|
|
179
|
+
testql_bin=testql_bin,
|
|
180
|
+
browser_service_url=browser_service_url,
|
|
181
|
+
track_dir=track_dir,
|
|
182
|
+
quick_limit=quick_limit,
|
|
183
|
+
config=wup_config,
|
|
184
|
+
)
|
|
98
185
|
|
|
99
|
-
if mode.lower() == "testql":
|
|
100
|
-
watcher = TestQLWatcher(
|
|
101
|
-
project_root=str(project_path),
|
|
102
|
-
deps_file=deps_file,
|
|
103
|
-
cpu_throttle=cpu_throttle,
|
|
104
|
-
debounce_seconds=debounce,
|
|
105
|
-
test_cooldown_seconds=cooldown,
|
|
106
|
-
scenarios_dir=effective_scenarios_dir,
|
|
107
|
-
testql_bin=testql_bin,
|
|
108
|
-
browser_service_url=browser_service_url,
|
|
109
|
-
track_dir=track_dir,
|
|
110
|
-
quick_limit=quick_limit,
|
|
111
|
-
config=wup_config,
|
|
112
|
-
)
|
|
113
|
-
console.print("[green]TestQL mode enabled[/green]")
|
|
114
|
-
else:
|
|
115
|
-
watcher = WupWatcher(
|
|
116
|
-
project_root=str(project_path),
|
|
117
|
-
deps_file=deps_file,
|
|
118
|
-
cpu_throttle=cpu_throttle,
|
|
119
|
-
debounce_seconds=debounce,
|
|
120
|
-
test_cooldown_seconds=cooldown,
|
|
121
|
-
config=wup_config,
|
|
122
|
-
)
|
|
123
|
-
|
|
124
186
|
if dashboard:
|
|
125
187
|
console.print("[green]Starting watcher with live dashboard...[/green]")
|
|
126
188
|
asyncio.run(watcher.run_with_dashboard())
|
|
@@ -3,6 +3,7 @@ Core module for WUP (What's Up) - Intelligent file watcher for regression testin
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import errno
|
|
6
7
|
import json
|
|
7
8
|
import subprocess
|
|
8
9
|
import time
|
|
@@ -18,6 +19,7 @@ from rich.live import Live
|
|
|
18
19
|
from rich.table import Table
|
|
19
20
|
from watchdog.events import FileSystemEventHandler
|
|
20
21
|
from watchdog.observers import Observer
|
|
22
|
+
from watchdog.observers.polling import PollingObserver
|
|
21
23
|
|
|
22
24
|
from .config import load_config
|
|
23
25
|
from .dependency_mapper import DependencyMapper
|
|
@@ -496,6 +498,31 @@ class WupWatcher:
|
|
|
496
498
|
str(self.project_root / "tests"),
|
|
497
499
|
]
|
|
498
500
|
|
|
501
|
+
def _create_and_start_observer(self, event_handler, watch_paths):
|
|
502
|
+
"""
|
|
503
|
+
Create and start a file system observer, falling back to polling
|
|
504
|
+
if the inotify watch limit is reached.
|
|
505
|
+
"""
|
|
506
|
+
observer = Observer()
|
|
507
|
+
for path in watch_paths:
|
|
508
|
+
observer.schedule(event_handler, path, recursive=True)
|
|
509
|
+
try:
|
|
510
|
+
observer.start()
|
|
511
|
+
return observer
|
|
512
|
+
except OSError as exc:
|
|
513
|
+
if exc.errno in (errno.ENOSPC, errno.EMFILE):
|
|
514
|
+
self.console.print(
|
|
515
|
+
f"[yellow]⚠️ inotify limit reached ({exc.strerror}). "
|
|
516
|
+
"Falling back to polling observer (higher CPU usage).[/yellow]"
|
|
517
|
+
)
|
|
518
|
+
observer.stop()
|
|
519
|
+
observer = PollingObserver()
|
|
520
|
+
for path in watch_paths:
|
|
521
|
+
observer.schedule(event_handler, path, recursive=True)
|
|
522
|
+
observer.start()
|
|
523
|
+
return observer
|
|
524
|
+
raise
|
|
525
|
+
|
|
499
526
|
def start_watching(self, watch_paths: Optional[List[str]] = None):
|
|
500
527
|
"""
|
|
501
528
|
Start watching for file changes.
|
|
@@ -514,12 +541,7 @@ class WupWatcher:
|
|
|
514
541
|
return
|
|
515
542
|
|
|
516
543
|
event_handler = WupEventHandler(self)
|
|
517
|
-
observer =
|
|
518
|
-
|
|
519
|
-
for path in watch_paths:
|
|
520
|
-
observer.schedule(event_handler, path, recursive=True)
|
|
521
|
-
|
|
522
|
-
observer.start()
|
|
544
|
+
observer = self._create_and_start_observer(event_handler, watch_paths)
|
|
523
545
|
self.console.print(f"[green]🕵️ Watching: {', '.join(watch_paths)}[/green]")
|
|
524
546
|
|
|
525
547
|
try:
|
|
@@ -564,18 +586,11 @@ class WupWatcher:
|
|
|
564
586
|
|
|
565
587
|
async def run_with_dashboard(self):
|
|
566
588
|
"""Run watcher with live dashboard."""
|
|
567
|
-
from watchdog.observers import Observer
|
|
568
|
-
|
|
569
589
|
watch_paths = self.build_watched_paths()
|
|
570
590
|
watch_paths = [p for p in watch_paths if Path(p).exists()]
|
|
571
591
|
|
|
572
592
|
event_handler = WupEventHandler(self)
|
|
573
|
-
observer =
|
|
574
|
-
|
|
575
|
-
for path in watch_paths:
|
|
576
|
-
observer.schedule(event_handler, path, recursive=True)
|
|
577
|
-
|
|
578
|
-
observer.start()
|
|
593
|
+
observer = self._create_and_start_observer(event_handler, watch_paths)
|
|
579
594
|
|
|
580
595
|
with Live(self.create_status_table(), refresh_per_second=1) as live:
|
|
581
596
|
try:
|
|
@@ -38,6 +38,51 @@ def _parse_port_mapping(raw: Any) -> List[str]:
|
|
|
38
38
|
return []
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
def _load_compose_yaml(compose_path: Path) -> Optional[Dict[str, Any]]:
|
|
42
|
+
"""Load and validate a docker-compose YAML file."""
|
|
43
|
+
try:
|
|
44
|
+
data = yaml.safe_load(compose_path.read_text(encoding="utf-8"))
|
|
45
|
+
except (OSError, yaml.YAMLError):
|
|
46
|
+
return None
|
|
47
|
+
if not isinstance(data, dict):
|
|
48
|
+
return None
|
|
49
|
+
services = data.get("services") or {}
|
|
50
|
+
if not isinstance(services, dict):
|
|
51
|
+
return None
|
|
52
|
+
return services
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _extract_healthcheck_test(spec: Dict[str, Any]) -> str:
|
|
56
|
+
"""Extract healthcheck test command from service spec."""
|
|
57
|
+
hc = spec.get("healthcheck") or {}
|
|
58
|
+
if not isinstance(hc, dict) or not hc.get("test"):
|
|
59
|
+
return ""
|
|
60
|
+
parts = hc["test"]
|
|
61
|
+
if isinstance(parts, list):
|
|
62
|
+
return " ".join(str(p) for p in parts)
|
|
63
|
+
return str(parts)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _extract_service_from_spec(
|
|
67
|
+
name: str, spec: Dict[str, Any], source_file: str
|
|
68
|
+
) -> Optional[DockerComposeService]:
|
|
69
|
+
"""Build a DockerComposeService from compose spec."""
|
|
70
|
+
if not isinstance(spec, dict):
|
|
71
|
+
return None
|
|
72
|
+
profiles = spec.get("profiles") or []
|
|
73
|
+
if isinstance(profiles, str):
|
|
74
|
+
profiles = [profiles]
|
|
75
|
+
return DockerComposeService(
|
|
76
|
+
compose_service=name,
|
|
77
|
+
container_name=str(spec.get("container_name") or ""),
|
|
78
|
+
image=str(spec.get("image") or ""),
|
|
79
|
+
host_ports=_parse_port_mapping(spec.get("ports")),
|
|
80
|
+
profiles=[str(p) for p in profiles],
|
|
81
|
+
healthcheck_test=_extract_healthcheck_test(spec),
|
|
82
|
+
source_file=source_file,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
41
86
|
def discover_docker_compose_services(project_root: Path) -> List[DockerComposeService]:
|
|
42
87
|
"""Parse docker-compose*.yml service definitions under project root."""
|
|
43
88
|
patterns = ["docker-compose.yml", "docker-compose.*.yml", "docker-compose.*.yaml"]
|
|
@@ -50,44 +95,13 @@ def discover_docker_compose_services(project_root: Path) -> List[DockerComposeSe
|
|
|
50
95
|
if key in seen:
|
|
51
96
|
continue
|
|
52
97
|
seen.add(key)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
except (OSError, yaml.YAMLError):
|
|
56
|
-
continue
|
|
57
|
-
if not isinstance(data, dict):
|
|
58
|
-
continue
|
|
59
|
-
|
|
60
|
-
services = data.get("services") or {}
|
|
61
|
-
if not isinstance(services, dict):
|
|
98
|
+
services = _load_compose_yaml(compose_path)
|
|
99
|
+
if services is None:
|
|
62
100
|
continue
|
|
63
|
-
|
|
64
101
|
for name, spec in services.items():
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
hc_test = ""
|
|
69
|
-
if isinstance(hc, dict) and hc.get("test"):
|
|
70
|
-
parts = hc["test"]
|
|
71
|
-
if isinstance(parts, list):
|
|
72
|
-
hc_test = " ".join(str(p) for p in parts)
|
|
73
|
-
else:
|
|
74
|
-
hc_test = str(parts)
|
|
75
|
-
|
|
76
|
-
profiles = spec.get("profiles") or []
|
|
77
|
-
if isinstance(profiles, str):
|
|
78
|
-
profiles = [profiles]
|
|
79
|
-
|
|
80
|
-
results.append(
|
|
81
|
-
DockerComposeService(
|
|
82
|
-
compose_service=name,
|
|
83
|
-
container_name=str(spec.get("container_name") or ""),
|
|
84
|
-
image=str(spec.get("image") or ""),
|
|
85
|
-
host_ports=_parse_port_mapping(spec.get("ports")),
|
|
86
|
-
profiles=[str(p) for p in profiles],
|
|
87
|
-
healthcheck_test=hc_test,
|
|
88
|
-
source_file=compose_path.name,
|
|
89
|
-
)
|
|
90
|
-
)
|
|
102
|
+
svc = _extract_service_from_spec(name, spec, compose_path.name)
|
|
103
|
+
if svc is not None:
|
|
104
|
+
results.append(svc)
|
|
91
105
|
return results
|
|
92
106
|
|
|
93
107
|
|
|
@@ -136,27 +150,26 @@ def _probe_row(probe: ProbeTarget) -> Dict[str, Any]:
|
|
|
136
150
|
}
|
|
137
151
|
|
|
138
152
|
|
|
139
|
-
def
|
|
140
|
-
"""
|
|
141
|
-
|
|
142
|
-
wup_names = [s.name for s in config.services]
|
|
143
|
-
docker_all = discover_docker_compose_services(project_root)
|
|
144
|
-
|
|
145
|
-
by_wup: Dict[str, Dict[str, Any]] = {
|
|
153
|
+
def _build_wup_service_dicts(config: WupConfig) -> Dict[str, Dict[str, Any]]:
|
|
154
|
+
"""Initialize per-service manifest buckets."""
|
|
155
|
+
return {
|
|
146
156
|
name: {
|
|
147
|
-
"wup_paths":
|
|
157
|
+
"wup_paths": list(svc.paths),
|
|
148
158
|
"docker": [],
|
|
149
159
|
"live_probes": [],
|
|
150
160
|
"testql_dry_run_scenarios": [],
|
|
151
161
|
}
|
|
152
|
-
for name in
|
|
162
|
+
for name, svc in {s.name: s for s in config.services}.items()
|
|
153
163
|
}
|
|
154
164
|
|
|
155
|
-
for svc in config.services:
|
|
156
|
-
by_wup[svc.name]["wup_paths"] = list(svc.paths)
|
|
157
165
|
|
|
158
|
-
|
|
159
|
-
|
|
166
|
+
def _build_docker_rows(
|
|
167
|
+
docker_all: List[DockerComposeService],
|
|
168
|
+
wup_names: List[str],
|
|
169
|
+
by_wup: Dict[str, Dict[str, Any]],
|
|
170
|
+
) -> List[Dict[str, Any]]:
|
|
171
|
+
"""Group docker-compose rows under WUP services; return unmapped leftovers."""
|
|
172
|
+
unmapped: List[Dict[str, Any]] = []
|
|
160
173
|
for d in docker_all:
|
|
161
174
|
row = {
|
|
162
175
|
"compose_service": d.compose_service,
|
|
@@ -172,22 +185,43 @@ def build_monitoring_manifest(project_root: Path, config: WupConfig) -> Dict[str
|
|
|
172
185
|
if mapped:
|
|
173
186
|
by_wup[mapped]["docker"].append(row)
|
|
174
187
|
else:
|
|
175
|
-
|
|
188
|
+
unmapped.append(row)
|
|
189
|
+
return unmapped
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _build_scenario_rows(
|
|
193
|
+
monitor: TestQLMonitor,
|
|
194
|
+
project_root: Path,
|
|
195
|
+
wup_names: List[str],
|
|
196
|
+
by_wup: Dict[str, Dict[str, Any]],
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Attach scenario file paths to the matching WUP service entries."""
|
|
199
|
+
if not monitor.discovery.scenarios_dir.exists():
|
|
200
|
+
return
|
|
201
|
+
for scenario in monitor.discovery.discover_scenarios():
|
|
202
|
+
rel = str(scenario.relative_to(project_root))
|
|
203
|
+
tokens = scenario.stem.lower()
|
|
204
|
+
for svc_name in wup_names:
|
|
205
|
+
token = svc_name.lower().replace("_", "-")
|
|
206
|
+
if token in tokens:
|
|
207
|
+
by_wup[svc_name]["testql_dry_run_scenarios"].append(rel)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def build_monitoring_manifest(project_root: Path, config: WupConfig) -> Dict[str, Any]:
|
|
211
|
+
"""Assemble full monitoring manifest for wup.yaml (documentation + audit)."""
|
|
212
|
+
monitor = TestQLMonitor(project_root, config)
|
|
213
|
+
wup_names = [s.name for s in config.services]
|
|
214
|
+
docker_all = discover_docker_compose_services(project_root)
|
|
215
|
+
|
|
216
|
+
by_wup = _build_wup_service_dicts(config)
|
|
217
|
+
unmapped_docker = _build_docker_rows(docker_all, wup_names, by_wup)
|
|
176
218
|
|
|
177
219
|
# Live HTTP probes (exactly what WUP will call)
|
|
178
220
|
for svc_name in wup_names:
|
|
179
221
|
probes = monitor.probes_for_service(svc_name)
|
|
180
222
|
by_wup[svc_name]["live_probes"] = [_probe_row(p) for p in probes]
|
|
181
223
|
|
|
182
|
-
|
|
183
|
-
if monitor.discovery.scenarios_dir.exists():
|
|
184
|
-
for scenario in monitor.discovery.discover_scenarios():
|
|
185
|
-
rel = str(scenario.relative_to(project_root))
|
|
186
|
-
tokens = scenario.stem.lower()
|
|
187
|
-
for svc_name in wup_names:
|
|
188
|
-
token = svc_name.lower().replace("_", "-")
|
|
189
|
-
if token in tokens:
|
|
190
|
-
by_wup[svc_name]["testql_dry_run_scenarios"].append(rel)
|
|
224
|
+
_build_scenario_rows(monitor, project_root, wup_names, by_wup)
|
|
191
225
|
|
|
192
226
|
tq = config.testql
|
|
193
227
|
return {
|
|
@@ -85,6 +85,27 @@ def parse_scenario_probes(scenario_path: Path) -> List[ProbeTarget]:
|
|
|
85
85
|
return _parse_api_lines(content, source=str(scenario_path))
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def _extract_base_url(data: Dict[str, Any]) -> str:
|
|
89
|
+
"""Read base_url / api_base_url from service map YAML header."""
|
|
90
|
+
service = data.get("service")
|
|
91
|
+
if isinstance(service, dict):
|
|
92
|
+
return str(service.get("base_url") or service.get("api_base_url") or "").rstrip("/")
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_endpoint_row(row: Any, base_url: str, source: str) -> Optional[ProbeTarget]:
|
|
97
|
+
"""Convert a single endpoints list entry into a ProbeTarget."""
|
|
98
|
+
if not isinstance(row, dict):
|
|
99
|
+
return None
|
|
100
|
+
path = str(row.get("path") or "").strip()
|
|
101
|
+
if not path:
|
|
102
|
+
return None
|
|
103
|
+
method = str(row.get("method") or "GET").upper()
|
|
104
|
+
expected = int(row.get("expected_status") or 200)
|
|
105
|
+
url = path if path.startswith("http") else f"{base_url}{path}" if base_url else path
|
|
106
|
+
return ProbeTarget(url=url, method=method, expected_status=expected, source=source)
|
|
107
|
+
|
|
108
|
+
|
|
88
109
|
def parse_service_map_probes(map_path: Path) -> List[ProbeTarget]:
|
|
89
110
|
"""Extract probes from c2004-style service map YAML (endpoints: list)."""
|
|
90
111
|
try:
|
|
@@ -95,22 +116,13 @@ def parse_service_map_probes(map_path: Path) -> List[ProbeTarget]:
|
|
|
95
116
|
if not isinstance(data, dict):
|
|
96
117
|
return []
|
|
97
118
|
|
|
98
|
-
base_url =
|
|
99
|
-
|
|
100
|
-
if isinstance(service, dict):
|
|
101
|
-
base_url = str(service.get("base_url") or service.get("api_base_url") or "").rstrip("/")
|
|
102
|
-
|
|
119
|
+
base_url = _extract_base_url(data)
|
|
120
|
+
source = str(map_path)
|
|
103
121
|
probes: List[ProbeTarget] = []
|
|
104
122
|
for row in data.get("endpoints") or []:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
path = str(row.get("path") or "").strip()
|
|
109
|
-
if not path:
|
|
110
|
-
continue
|
|
111
|
-
expected = int(row.get("expected_status") or 200)
|
|
112
|
-
url = path if path.startswith("http") else f"{base_url}{path}" if base_url else path
|
|
113
|
-
probes.append(ProbeTarget(url=url, method=method, expected_status=expected, source=str(map_path)))
|
|
123
|
+
probe = _parse_endpoint_row(row, base_url, source)
|
|
124
|
+
if probe is not None:
|
|
125
|
+
probes.append(probe)
|
|
114
126
|
return probes
|
|
115
127
|
|
|
116
128
|
|
|
@@ -167,35 +179,39 @@ def _service_path_patterns(services: Sequence[ServiceConfig]) -> Dict[str, List[
|
|
|
167
179
|
return patterns
|
|
168
180
|
|
|
169
181
|
|
|
170
|
-
def
|
|
171
|
-
|
|
182
|
+
def _assign_http_probe(
|
|
183
|
+
probe: ProbeTarget, services: Sequence[ServiceConfig], path_lower: str
|
|
184
|
+
) -> Optional[str]:
|
|
185
|
+
"""Map an HTTP probe to a service based on port and path."""
|
|
172
186
|
wup_names = {s.name.lower() for s in services}
|
|
173
|
-
|
|
174
|
-
|
|
187
|
+
parsed = urlparse(probe.url)
|
|
188
|
+
port = parsed.port
|
|
175
189
|
|
|
176
|
-
if
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
190
|
+
if port == 8101 and "backend" in wup_names:
|
|
191
|
+
return next(s.name for s in services if s.name.lower() == "backend")
|
|
192
|
+
if port == 8202:
|
|
193
|
+
for svc in services:
|
|
194
|
+
if "firmware" in svc.name.lower():
|
|
195
|
+
return svc.name
|
|
196
|
+
if port == 8100:
|
|
197
|
+
if path_lower.startswith("/firmware"):
|
|
182
198
|
for svc in services:
|
|
183
199
|
if "firmware" in svc.name.lower():
|
|
184
200
|
return svc.name
|
|
185
|
-
if
|
|
186
|
-
if
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
token = svc.name.lower().replace("_", "-")
|
|
195
|
-
if token.startswith("connect-") and token.replace("connect-", "") in path_lower:
|
|
196
|
-
return svc.name
|
|
197
|
-
return None
|
|
201
|
+
if "frontend" in wup_names:
|
|
202
|
+
return next(s.name for s in services if s.name.lower() == "frontend")
|
|
203
|
+
# Connect-* backends on 8103+ — only if a matching WUP service exists
|
|
204
|
+
for svc in services:
|
|
205
|
+
token = svc.name.lower().replace("_", "-")
|
|
206
|
+
if token.startswith("connect-") and token.replace("connect-", "") in path_lower:
|
|
207
|
+
return svc.name
|
|
208
|
+
return None
|
|
209
|
+
|
|
198
210
|
|
|
211
|
+
def _assign_by_longest_token(
|
|
212
|
+
path_lower: str, services: Sequence[ServiceConfig]
|
|
213
|
+
) -> Optional[str]:
|
|
214
|
+
"""Match path to service with the longest token match."""
|
|
199
215
|
best: Optional[str] = None
|
|
200
216
|
best_len = -1
|
|
201
217
|
for svc in services:
|
|
@@ -205,10 +221,13 @@ def assign_probe_to_service(probe: ProbeTarget, services: Sequence[ServiceConfig
|
|
|
205
221
|
if token in path_lower and len(token) > best_len:
|
|
206
222
|
best = svc.name
|
|
207
223
|
best_len = len(token)
|
|
224
|
+
return best
|
|
208
225
|
|
|
209
|
-
if best:
|
|
210
|
-
return best
|
|
211
226
|
|
|
227
|
+
def _assign_by_path_prefix(
|
|
228
|
+
path_lower: str, services: Sequence[ServiceConfig]
|
|
229
|
+
) -> Optional[str]:
|
|
230
|
+
"""Fallback mapping based on known path prefixes."""
|
|
212
231
|
if path_lower.startswith("/connect-"):
|
|
213
232
|
for svc in services:
|
|
214
233
|
if svc.name.lower() == "frontend":
|
|
@@ -228,6 +247,44 @@ def assign_probe_to_service(probe: ProbeTarget, services: Sequence[ServiceConfig
|
|
|
228
247
|
return None
|
|
229
248
|
|
|
230
249
|
|
|
250
|
+
def assign_probe_to_service(probe: ProbeTarget, services: Sequence[ServiceConfig]) -> Optional[str]:
|
|
251
|
+
"""Map a probe URL/path to a configured WUP service name."""
|
|
252
|
+
path = urlparse(probe.url).path if probe.url.startswith("http") else probe.url
|
|
253
|
+
path_lower = path.lower()
|
|
254
|
+
|
|
255
|
+
if probe.url.startswith("http"):
|
|
256
|
+
result = _assign_http_probe(probe, services, path_lower)
|
|
257
|
+
if result:
|
|
258
|
+
return result
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
best = _assign_by_longest_token(path_lower, services)
|
|
262
|
+
if best:
|
|
263
|
+
return best
|
|
264
|
+
|
|
265
|
+
return _assign_by_path_prefix(path_lower, services)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class _ProbeAccumulator:
|
|
269
|
+
"""Deduplicated probe collector for discover_probes_by_service."""
|
|
270
|
+
|
|
271
|
+
def __init__(self, services: Sequence[ServiceConfig]):
|
|
272
|
+
self.by_service: Dict[str, List[ProbeTarget]] = {
|
|
273
|
+
svc.name: [] for svc in services
|
|
274
|
+
}
|
|
275
|
+
self._seen: Dict[str, set] = {name: set() for name in self.by_service}
|
|
276
|
+
|
|
277
|
+
def add(self, service: str, probe: ProbeTarget) -> None:
|
|
278
|
+
if service not in self.by_service:
|
|
279
|
+
self.by_service[service] = []
|
|
280
|
+
self._seen[service] = set()
|
|
281
|
+
key = f"{probe.method}:{probe.url}"
|
|
282
|
+
if key in self._seen[service]:
|
|
283
|
+
return
|
|
284
|
+
self._seen[service].add(key)
|
|
285
|
+
self.by_service[service].append(probe)
|
|
286
|
+
|
|
287
|
+
|
|
231
288
|
class TestQLMonitor:
|
|
232
289
|
"""Build and run live probes from TestQL scenarios + WUP config."""
|
|
233
290
|
|
|
@@ -247,24 +304,11 @@ class TestQLMonitor:
|
|
|
247
304
|
paths.extend(sorted(self.project_root.glob(pattern)))
|
|
248
305
|
return paths
|
|
249
306
|
|
|
250
|
-
def
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
seen: Dict[str, set] = {name: set() for name in by_service}
|
|
256
|
-
|
|
257
|
-
def add(service: str, probe: ProbeTarget) -> None:
|
|
258
|
-
if service not in by_service:
|
|
259
|
-
by_service[service] = []
|
|
260
|
-
seen[service] = set()
|
|
261
|
-
key = f"{probe.method}:{probe.url}"
|
|
262
|
-
if key in seen[service]:
|
|
263
|
-
return
|
|
264
|
-
seen[service].add(key)
|
|
265
|
-
by_service[service].append(probe)
|
|
266
|
-
|
|
267
|
-
# 1) Config-declared endpoints (paths or full URLs) — per-service base URL
|
|
307
|
+
def _add_config_endpoints(
|
|
308
|
+
self,
|
|
309
|
+
accumulator: "_ProbeAccumulator",
|
|
310
|
+
) -> None:
|
|
311
|
+
"""Add config-declared endpoints (paths or full URLs) per-service base URL."""
|
|
268
312
|
for svc_name, paths in (self.config.testql.endpoints_by_service or {}).items():
|
|
269
313
|
base = self._resolve_base_url_for_service(svc_name)
|
|
270
314
|
for path in paths:
|
|
@@ -273,7 +317,7 @@ class TestQLMonitor:
|
|
|
273
317
|
continue
|
|
274
318
|
probe = ProbeTarget(url=url, source="wup.yaml:endpoints_by_service")
|
|
275
319
|
if is_monitoring_probe(probe):
|
|
276
|
-
add(svc_name, probe)
|
|
320
|
+
accumulator.add(svc_name, probe)
|
|
277
321
|
|
|
278
322
|
for path in self.config.testql.explicit_endpoints or []:
|
|
279
323
|
probe = ProbeTarget(url=path, source="wup.yaml:explicit_endpoints")
|
|
@@ -286,30 +330,45 @@ class TestQLMonitor:
|
|
|
286
330
|
continue
|
|
287
331
|
probe = ProbeTarget(url=url, source="wup.yaml:explicit_endpoints")
|
|
288
332
|
if is_monitoring_probe(probe):
|
|
289
|
-
add(assigned, probe)
|
|
290
|
-
|
|
291
|
-
if not self.config.testql.endpoint_discovery:
|
|
292
|
-
return by_service
|
|
333
|
+
accumulator.add(assigned, probe)
|
|
293
334
|
|
|
294
|
-
|
|
335
|
+
def _add_scenario_probes(
|
|
336
|
+
self,
|
|
337
|
+
accumulator: "_ProbeAccumulator",
|
|
338
|
+
) -> None:
|
|
339
|
+
"""Add TestQL scenario probes mapped to services."""
|
|
295
340
|
for scenario in self.discovery.discover_scenarios():
|
|
296
341
|
for probe in parse_scenario_probes(scenario):
|
|
297
342
|
if not is_monitoring_probe(probe):
|
|
298
343
|
continue
|
|
299
344
|
assigned = assign_probe_to_service(probe, self.config.services)
|
|
300
345
|
if assigned:
|
|
301
|
-
add(assigned, probe)
|
|
346
|
+
accumulator.add(assigned, probe)
|
|
302
347
|
|
|
303
|
-
|
|
348
|
+
def _add_service_map_probes(
|
|
349
|
+
self,
|
|
350
|
+
accumulator: "_ProbeAccumulator",
|
|
351
|
+
) -> None:
|
|
352
|
+
"""Add service-map TOON/YAML probes mapped to services."""
|
|
304
353
|
for map_path in self._service_map_paths():
|
|
305
354
|
for probe in parse_service_map_probes(map_path):
|
|
306
355
|
if not is_monitoring_probe(probe):
|
|
307
356
|
continue
|
|
308
357
|
assigned = assign_probe_to_service(probe, self.config.services)
|
|
309
358
|
if assigned:
|
|
310
|
-
add(assigned, probe)
|
|
359
|
+
accumulator.add(assigned, probe)
|
|
360
|
+
|
|
361
|
+
def discover_probes_by_service(self) -> Dict[str, List[ProbeTarget]]:
|
|
362
|
+
"""Discover monitoring probes grouped by WUP service name."""
|
|
363
|
+
accumulator = _ProbeAccumulator(self.config.services)
|
|
364
|
+
|
|
365
|
+
self._add_config_endpoints(accumulator)
|
|
366
|
+
|
|
367
|
+
if self.config.testql.endpoint_discovery:
|
|
368
|
+
self._add_scenario_probes(accumulator)
|
|
369
|
+
self._add_service_map_probes(accumulator)
|
|
311
370
|
|
|
312
|
-
return by_service
|
|
371
|
+
return accumulator.by_service
|
|
313
372
|
|
|
314
373
|
def _resolve_base_url_for_service(self, service: str) -> str:
|
|
315
374
|
tq = self.config.testql
|
|
@@ -510,33 +510,52 @@ class TestQLWatcher(WupWatcher):
|
|
|
510
510
|
return False
|
|
511
511
|
|
|
512
512
|
@staticmethod
|
|
513
|
-
def
|
|
514
|
-
"""
|
|
515
|
-
blob = "\n".join(part for part in (result.stdout or "", result.stderr or "") if part).strip()
|
|
516
|
-
if not blob:
|
|
517
|
-
return "health_scenario failed"
|
|
518
|
-
|
|
513
|
+
def _try_parse_json_summary(blob: str) -> Optional[str]:
|
|
514
|
+
"""Try to extract passed/failed summary from trailing JSON in blob."""
|
|
519
515
|
start = blob.rfind("{")
|
|
520
|
-
if start
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
516
|
+
if start < 0:
|
|
517
|
+
return None
|
|
518
|
+
try:
|
|
519
|
+
data = json.loads(blob[start:])
|
|
520
|
+
except json.JSONDecodeError:
|
|
521
|
+
return None
|
|
522
|
+
if not isinstance(data, dict):
|
|
523
|
+
return None
|
|
524
|
+
passed = data.get("passed")
|
|
525
|
+
failed = data.get("failed")
|
|
526
|
+
if not isinstance(passed, int) or not isinstance(failed, int):
|
|
527
|
+
return None
|
|
528
|
+
total = passed + failed
|
|
529
|
+
errors = data.get("errors") or []
|
|
530
|
+
hint = f" — {errors[0]}" if errors else ""
|
|
531
|
+
return f"{passed}/{total} passed, {failed} failed{hint}"
|
|
533
532
|
|
|
533
|
+
@staticmethod
|
|
534
|
+
def _try_find_line_summary(blob: str) -> Optional[str]:
|
|
535
|
+
"""Find a meaningful summary line by scanning from the end of blob."""
|
|
534
536
|
for line in reversed(blob.splitlines()):
|
|
535
537
|
stripped = line.strip()
|
|
536
538
|
if not stripped or stripped in {"}", "{"}:
|
|
537
539
|
continue
|
|
538
540
|
if "passed" in stripped.lower() or "failed" in stripped.lower() or "❌" in stripped:
|
|
539
541
|
return stripped
|
|
542
|
+
return None
|
|
543
|
+
|
|
544
|
+
@staticmethod
|
|
545
|
+
def _summarize_health_scenario_failure(result: subprocess.CompletedProcess) -> str:
|
|
546
|
+
"""Extract a short human summary from TestQL --output json (avoid trailing '}')."""
|
|
547
|
+
blob = "\n".join(part for part in (result.stdout or "", result.stderr or "") if part).strip()
|
|
548
|
+
if not blob:
|
|
549
|
+
return "health_scenario failed"
|
|
550
|
+
|
|
551
|
+
summary = TestQLWatcher._try_parse_json_summary(blob)
|
|
552
|
+
if summary:
|
|
553
|
+
return summary
|
|
554
|
+
|
|
555
|
+
summary = TestQLWatcher._try_find_line_summary(blob)
|
|
556
|
+
if summary:
|
|
557
|
+
return summary
|
|
558
|
+
|
|
540
559
|
return "health_scenario failed"
|
|
541
560
|
|
|
542
561
|
async def _run_fleet_health_scenario(self) -> bool:
|
|
@@ -349,6 +349,72 @@ class VisualDiffer:
|
|
|
349
349
|
result.append(url)
|
|
350
350
|
return result
|
|
351
351
|
|
|
352
|
+
def _categorize_page_result(
|
|
353
|
+
self,
|
|
354
|
+
service: str,
|
|
355
|
+
url: str,
|
|
356
|
+
result: Dict[str, Any],
|
|
357
|
+
ok_urls: List[str],
|
|
358
|
+
new_urls: List[str],
|
|
359
|
+
error_results: List[Tuple[str, str]],
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Classify a single page result and append to the appropriate bucket."""
|
|
362
|
+
status = result["diff"]["status"]
|
|
363
|
+
if status in {"changed", "issue"}:
|
|
364
|
+
self._write_diff_event(service, url, result)
|
|
365
|
+
if status == "issue":
|
|
366
|
+
console.print(
|
|
367
|
+
f"[bold red]🚨 Page issue: {service} {url}[/bold red] "
|
|
368
|
+
f"{'; '.join(result['diff'].get('issues', []))}"
|
|
369
|
+
)
|
|
370
|
+
else:
|
|
371
|
+
console.print(
|
|
372
|
+
f"[bold yellow]🔍 Visual diff: {service} {url}[/bold yellow] "
|
|
373
|
+
f"+{result['diff']['counts']['added']} "
|
|
374
|
+
f"-{result['diff']['counts']['removed']} "
|
|
375
|
+
f"~{result['diff']['counts']['changed_attrs']}"
|
|
376
|
+
)
|
|
377
|
+
elif status == "new":
|
|
378
|
+
new_urls.append(_short_url(url))
|
|
379
|
+
elif status == "error":
|
|
380
|
+
message = result["diff"].get("message", "scan failed")
|
|
381
|
+
error_results.append((_short_url(url), message))
|
|
382
|
+
elif status == "ok":
|
|
383
|
+
ok_urls.append(_short_url(url))
|
|
384
|
+
|
|
385
|
+
def _print_scan_summary(
|
|
386
|
+
self,
|
|
387
|
+
service: str,
|
|
388
|
+
ok_urls: List[str],
|
|
389
|
+
new_urls: List[str],
|
|
390
|
+
error_results: List[Tuple[str, str]],
|
|
391
|
+
) -> None:
|
|
392
|
+
"""Print summary after scanning all pages for a service."""
|
|
393
|
+
if new_urls:
|
|
394
|
+
console.print(
|
|
395
|
+
f"[dim]📷 Baseline snapshots for {service}: {len(new_urls)} page(s)"
|
|
396
|
+
f" — {_sample_list(new_urls)}[/dim]"
|
|
397
|
+
)
|
|
398
|
+
if ok_urls:
|
|
399
|
+
console.print(
|
|
400
|
+
f"[dim green]✓ No DOM change for {service}: {len(ok_urls)} page(s)"
|
|
401
|
+
f" — {_sample_list(ok_urls)}[/dim green]"
|
|
402
|
+
)
|
|
403
|
+
if error_results:
|
|
404
|
+
grouped_messages = Counter(
|
|
405
|
+
_compact_error_message(message or "scan failed")
|
|
406
|
+
for _, message in error_results
|
|
407
|
+
)
|
|
408
|
+
top_messages = [
|
|
409
|
+
f"{count}x {message}" for message, count in grouped_messages.most_common(2)
|
|
410
|
+
]
|
|
411
|
+
message_summary = "; ".join(top_messages)
|
|
412
|
+
failed_urls = [url for url, _ in error_results]
|
|
413
|
+
console.print(
|
|
414
|
+
f"[yellow]⚠ Visual diff skipped for {service}: {len(error_results)} page(s)"
|
|
415
|
+
f" failed to fetch — {message_summary}; sample: {_sample_list(failed_urls)}[/yellow]"
|
|
416
|
+
)
|
|
417
|
+
|
|
352
418
|
async def run_for_service(
|
|
353
419
|
self, service: str, endpoints: List[str]
|
|
354
420
|
) -> List[Dict[str, Any]]:
|
|
@@ -370,61 +436,18 @@ class VisualDiffer:
|
|
|
370
436
|
max_pages = max(1, int(self.cfg.max_pages or 5))
|
|
371
437
|
if len(pages) > max_pages:
|
|
372
438
|
pages = pages[:max_pages]
|
|
373
|
-
|
|
439
|
+
|
|
440
|
+
results: List[Dict[str, Any]] = []
|
|
374
441
|
ok_urls: List[str] = []
|
|
375
442
|
new_urls: List[str] = []
|
|
376
443
|
error_results: List[Tuple[str, str]] = []
|
|
444
|
+
|
|
377
445
|
for url in pages:
|
|
378
446
|
result = await self._check_page(service, url)
|
|
379
447
|
results.append(result)
|
|
380
|
-
|
|
381
|
-
if status in {"changed", "issue"}:
|
|
382
|
-
self._write_diff_event(service, url, result)
|
|
383
|
-
if status == "issue":
|
|
384
|
-
console.print(
|
|
385
|
-
f"[bold red]🚨 Page issue: {service} {url}[/bold red] "
|
|
386
|
-
f"{'; '.join(result['diff'].get('issues', []))}"
|
|
387
|
-
)
|
|
388
|
-
else:
|
|
389
|
-
console.print(
|
|
390
|
-
f"[bold yellow]🔍 Visual diff: {service} {url}[/bold yellow] "
|
|
391
|
-
f"+{result['diff']['counts']['added']} "
|
|
392
|
-
f"-{result['diff']['counts']['removed']} "
|
|
393
|
-
f"~{result['diff']['counts']['changed_attrs']}"
|
|
394
|
-
)
|
|
395
|
-
elif status == "new":
|
|
396
|
-
new_urls.append(_short_url(url))
|
|
397
|
-
elif status == "error":
|
|
398
|
-
message = result["diff"].get("message", "scan failed")
|
|
399
|
-
error_results.append((_short_url(url), message))
|
|
400
|
-
elif status == "ok":
|
|
401
|
-
ok_urls.append(_short_url(url))
|
|
402
|
-
|
|
403
|
-
if new_urls:
|
|
404
|
-
console.print(
|
|
405
|
-
f"[dim]📷 Baseline snapshots for {service}: {len(new_urls)} page(s)"
|
|
406
|
-
f" — {_sample_list(new_urls)}[/dim]"
|
|
407
|
-
)
|
|
408
|
-
if ok_urls:
|
|
409
|
-
console.print(
|
|
410
|
-
f"[dim green]✓ No DOM change for {service}: {len(ok_urls)} page(s)"
|
|
411
|
-
f" — {_sample_list(ok_urls)}[/dim green]"
|
|
412
|
-
)
|
|
413
|
-
if error_results:
|
|
414
|
-
grouped_messages = Counter(
|
|
415
|
-
_compact_error_message(message or "scan failed")
|
|
416
|
-
for _, message in error_results
|
|
417
|
-
)
|
|
418
|
-
top_messages = [
|
|
419
|
-
f"{count}x {message}" for message, count in grouped_messages.most_common(2)
|
|
420
|
-
]
|
|
421
|
-
message_summary = "; ".join(top_messages)
|
|
422
|
-
failed_urls = [url for url, _ in error_results]
|
|
423
|
-
console.print(
|
|
424
|
-
f"[yellow]⚠ Visual diff skipped for {service}: {len(error_results)} page(s)"
|
|
425
|
-
f" failed to fetch — {message_summary}; sample: {_sample_list(failed_urls)}[/yellow]"
|
|
426
|
-
)
|
|
448
|
+
self._categorize_page_result(service, url, result, ok_urls, new_urls, error_results)
|
|
427
449
|
|
|
450
|
+
self._print_scan_summary(service, ok_urls, new_urls, error_results)
|
|
428
451
|
return results
|
|
429
452
|
|
|
430
453
|
async def _check_page(self, service: str, url: str) -> Dict[str, Any]:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wup
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.33
|
|
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:** $2.
|
|
37
|
+
- 🤖 **LLM usage:** $2.5582 (43 commits)
|
|
38
38
|
- 👤 **Human dev:** ~$1832 (18.3h @ $100/h, 30min dedup)
|
|
39
39
|
|
|
40
40
|
Generated on 2026-05-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
41
41
|
|
|
42
42
|
---
|
|
43
43
|
|
|
44
|
-
    
|
|
45
45
|
|
|
46
46
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
47
47
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|