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.
Files changed (36) hide show
  1. {wup-0.2.32/wup.egg-info → wup-0.2.33}/PKG-INFO +5 -5
  2. {wup-0.2.32 → wup-0.2.33}/README.md +4 -4
  3. {wup-0.2.32 → wup-0.2.33}/pyproject.toml +1 -1
  4. {wup-0.2.32 → wup-0.2.33}/tests/test_wup.py +46 -0
  5. {wup-0.2.32 → wup-0.2.33}/wup/__init__.py +1 -1
  6. {wup-0.2.32 → wup-0.2.33}/wup/cli.py +115 -53
  7. {wup-0.2.32 → wup-0.2.33}/wup/core.py +29 -14
  8. {wup-0.2.32 → wup-0.2.33}/wup/monitoring_manifest.py +93 -59
  9. {wup-0.2.32 → wup-0.2.33}/wup/testql_monitor.py +126 -67
  10. {wup-0.2.32 → wup-0.2.33}/wup/testql_watcher.py +38 -19
  11. {wup-0.2.32 → wup-0.2.33}/wup/visual_diff.py +71 -48
  12. {wup-0.2.32 → wup-0.2.33/wup.egg-info}/PKG-INFO +5 -5
  13. {wup-0.2.32 → wup-0.2.33}/LICENSE +0 -0
  14. {wup-0.2.32 → wup-0.2.33}/setup.cfg +0 -0
  15. {wup-0.2.32 → wup-0.2.33}/tests/test_e2e.py +0 -0
  16. {wup-0.2.32 → wup-0.2.33}/tests/test_monitoring_manifest.py +0 -0
  17. {wup-0.2.32 → wup-0.2.33}/tests/test_testql_monitor.py +0 -0
  18. {wup-0.2.32 → wup-0.2.33}/tests/test_testql_watcher.py +0 -0
  19. {wup-0.2.32 → wup-0.2.33}/tests/test_web_client.py +0 -0
  20. {wup-0.2.32 → wup-0.2.33}/wup/_ast_detector.py +0 -0
  21. {wup-0.2.32 → wup-0.2.33}/wup/_hash_detector.py +0 -0
  22. {wup-0.2.32 → wup-0.2.33}/wup/_yaml_detector.py +0 -0
  23. {wup-0.2.32 → wup-0.2.33}/wup/anomaly_detector.py +0 -0
  24. {wup-0.2.32 → wup-0.2.33}/wup/anomaly_models.py +0 -0
  25. {wup-0.2.32 → wup-0.2.33}/wup/assistant.py +0 -0
  26. {wup-0.2.32 → wup-0.2.33}/wup/config.py +0 -0
  27. {wup-0.2.32 → wup-0.2.33}/wup/dependency_mapper.py +0 -0
  28. {wup-0.2.32 → wup-0.2.33}/wup/models/__init__.py +0 -0
  29. {wup-0.2.32 → wup-0.2.33}/wup/models/config.py +0 -0
  30. {wup-0.2.32 → wup-0.2.33}/wup/testql_discovery.py +0 -0
  31. {wup-0.2.32 → wup-0.2.33}/wup/web_client.py +0 -0
  32. {wup-0.2.32 → wup-0.2.33}/wup.egg-info/SOURCES.txt +0 -0
  33. {wup-0.2.32 → wup-0.2.33}/wup.egg-info/dependency_links.txt +0 -0
  34. {wup-0.2.32 → wup-0.2.33}/wup.egg-info/entry_points.txt +0 -0
  35. {wup-0.2.32 → wup-0.2.33}/wup.egg-info/requires.txt +0 -0
  36. {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.32
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.32-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.52-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-18.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
34
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.33-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.56-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-18.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
36
36
 
37
- - 🤖 **LLM usage:** $2.5178 (42 commits)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.32-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
44
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.33-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.32-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-$2.52-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-18.3h-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.33-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-$2.56-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-18.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $2.5178 (42 commits)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.32-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.33-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.32"
7
+ version = "0.2.33"
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.9"
@@ -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.32"
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
- console.print(f"[bold cyan]🚀 WUP Watcher[/bold cyan]")
79
- console.print(f"[dim]Project: {wup_config.project.name}[/dim]")
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
- if cfg_path:
91
- from .monitoring_manifest import build_monitoring_manifest, patch_wup_yaml_monitoring
92
- try:
93
- manifest = build_monitoring_manifest(project_path, wup_config)
94
- patch_wup_yaml_monitoring(cfg_path, manifest)
95
- console.print("[dim]Refreshed monitoring manifest in wup.yaml[/dim]")
96
- except OSError as exc:
97
- console.print(f"[yellow]Could not refresh monitoring manifest: {exc}[/yellow]")
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 = 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 = 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
- try:
54
- data = yaml.safe_load(compose_path.read_text(encoding="utf-8"))
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
- if not isinstance(spec, dict):
66
- continue
67
- hc = spec.get("healthcheck") or {}
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 build_monitoring_manifest(project_root: Path, config: WupConfig) -> Dict[str, Any]:
140
- """Assemble full monitoring manifest for wup.yaml (documentation + audit)."""
141
- monitor = TestQLMonitor(project_root, config)
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 wup_names
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
- # Docker rows grouped under WUP services (+ unmapped bucket)
159
- unmapped_docker: List[Dict[str, Any]] = []
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
- unmapped_docker.append(row)
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
- # Scenarios used for dry-run quick tests (informational)
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
- service = data.get("service")
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
- if not isinstance(row, dict):
106
- continue
107
- method = str(row.get("method") or "GET").upper()
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 assign_probe_to_service(probe: ProbeTarget, services: Sequence[ServiceConfig]) -> Optional[str]:
171
- """Map a probe URL/path to a configured WUP service name."""
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
- path = urlparse(probe.url).path if probe.url.startswith("http") else probe.url
174
- path_lower = path.lower()
187
+ parsed = urlparse(probe.url)
188
+ port = parsed.port
175
189
 
176
- if probe.url.startswith("http"):
177
- parsed = urlparse(probe.url)
178
- port = parsed.port
179
- if port == 8101 and "backend" in wup_names:
180
- return next(s.name for s in services if s.name.lower() == "backend")
181
- if port == 8202:
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 port == 8100:
186
- if path_lower.startswith("/firmware"):
187
- for svc in services:
188
- if "firmware" in svc.name.lower():
189
- return svc.name
190
- if "frontend" in wup_names:
191
- return next(s.name for s in services if s.name.lower() == "frontend")
192
- # Connect-* backends on 8103+ — only if a matching WUP service exists
193
- for svc in services:
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 discover_probes_by_service(self) -> Dict[str, List[ProbeTarget]]:
251
- """Discover monitoring probes grouped by WUP service name."""
252
- by_service: Dict[str, List[ProbeTarget]] = {
253
- svc.name: [] for svc in self.config.services
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
- # 2) TestQL scenarios — health/smoke API rows only
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
- # 3) Optional service-map TOON/YAML files
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 _summarize_health_scenario_failure(result: subprocess.CompletedProcess) -> str:
514
- """Extract a short human summary from TestQL --output json (avoid trailing '}')."""
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 >= 0:
521
- try:
522
- data = json.loads(blob[start:])
523
- except json.JSONDecodeError:
524
- data = None
525
- if isinstance(data, dict):
526
- passed = data.get("passed")
527
- failed = data.get("failed")
528
- if isinstance(passed, int) and isinstance(failed, int):
529
- total = passed + failed
530
- errors = data.get("errors") or []
531
- hint = f" — {errors[0]}" if errors else ""
532
- return f"{passed}/{total} passed, {failed} failed{hint}"
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
- results = []
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
- status = result["diff"]["status"]
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.32
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.32-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.52-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-18.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
34
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.33-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.56-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-18.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
36
36
 
37
- - 🤖 **LLM usage:** $2.5178 (42 commits)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.32-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
44
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.33-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
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