wup 0.2.46__tar.gz → 0.2.48__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 (54) hide show
  1. {wup-0.2.46/wup.egg-info → wup-0.2.48}/PKG-INFO +7 -7
  2. {wup-0.2.46 → wup-0.2.48}/README.md +6 -6
  3. {wup-0.2.46 → wup-0.2.48}/pyproject.toml +1 -1
  4. {wup-0.2.46 → wup-0.2.48}/tests/test_testql_watcher.py +38 -0
  5. wup-0.2.48/tests/test_visual_diff_periodic_skip.py +40 -0
  6. wup-0.2.48/tests/test_visual_diff_progress.py +39 -0
  7. {wup-0.2.46 → wup-0.2.48}/wup/__init__.py +1 -1
  8. {wup-0.2.46 → wup-0.2.48}/wup/models/config.py +3 -0
  9. {wup-0.2.46 → wup-0.2.48}/wup/testing/handlers/event_handlers.py +7 -1
  10. {wup-0.2.46 → wup-0.2.48}/wup/testql_watcher.py +51 -12
  11. {wup-0.2.46 → wup-0.2.48}/wup/visual_diff.py +42 -4
  12. {wup-0.2.46 → wup-0.2.48/wup.egg-info}/PKG-INFO +7 -7
  13. {wup-0.2.46 → wup-0.2.48}/wup.egg-info/SOURCES.txt +2 -0
  14. {wup-0.2.46 → wup-0.2.48}/LICENSE +0 -0
  15. {wup-0.2.46 → wup-0.2.48}/setup.cfg +0 -0
  16. {wup-0.2.46 → wup-0.2.48}/tests/test_auto_detection.py +0 -0
  17. {wup-0.2.46 → wup-0.2.48}/tests/test_cli_filtering.py +0 -0
  18. {wup-0.2.46 → wup-0.2.48}/tests/test_e2e.py +0 -0
  19. {wup-0.2.46 → wup-0.2.48}/tests/test_monitoring_manifest.py +0 -0
  20. {wup-0.2.46 → wup-0.2.48}/tests/test_service_inference.py +0 -0
  21. {wup-0.2.46 → wup-0.2.48}/tests/test_testql_monitor.py +0 -0
  22. {wup-0.2.46 → wup-0.2.48}/tests/test_web_client.py +0 -0
  23. {wup-0.2.46 → wup-0.2.48}/tests/test_wup.py +0 -0
  24. {wup-0.2.46 → wup-0.2.48}/wup/_ast_detector.py +0 -0
  25. {wup-0.2.46 → wup-0.2.48}/wup/_base_detector.py +0 -0
  26. {wup-0.2.46 → wup-0.2.48}/wup/_hash_detector.py +0 -0
  27. {wup-0.2.46 → wup-0.2.48}/wup/_yaml_detector.py +0 -0
  28. {wup-0.2.46 → wup-0.2.48}/wup/anomaly_detector.py +0 -0
  29. {wup-0.2.46 → wup-0.2.48}/wup/anomaly_models.py +0 -0
  30. {wup-0.2.46 → wup-0.2.48}/wup/assistant.py +0 -0
  31. {wup-0.2.46 → wup-0.2.48}/wup/bus.py +0 -0
  32. {wup-0.2.46 → wup-0.2.48}/wup/cli.py +0 -0
  33. {wup-0.2.46 → wup-0.2.48}/wup/cli_config_generator.py +0 -0
  34. {wup-0.2.46 → wup-0.2.48}/wup/cli_scanner.py +0 -0
  35. {wup-0.2.46 → wup-0.2.48}/wup/config.py +0 -0
  36. {wup-0.2.46 → wup-0.2.48}/wup/core.py +0 -0
  37. {wup-0.2.46 → wup-0.2.48}/wup/dependency_mapper.py +0 -0
  38. {wup-0.2.46 → wup-0.2.48}/wup/event_store.py +0 -0
  39. {wup-0.2.46 → wup-0.2.48}/wup/file_watcher/events/file_events.py +0 -0
  40. {wup-0.2.46 → wup-0.2.48}/wup/models/__init__.py +0 -0
  41. {wup-0.2.46 → wup-0.2.48}/wup/monitoring_manifest.py +0 -0
  42. {wup-0.2.46 → wup-0.2.48}/wup/planfile_reporter.py +0 -0
  43. {wup-0.2.46 → wup-0.2.48}/wup/testing/events/health_events.py +0 -0
  44. {wup-0.2.46 → wup-0.2.48}/wup/testing/events/test_results.py +0 -0
  45. {wup-0.2.46 → wup-0.2.48}/wup/testing/handlers/health_handlers.py +0 -0
  46. {wup-0.2.46 → wup-0.2.48}/wup/testing/queries/health_queries.py +0 -0
  47. {wup-0.2.46 → wup-0.2.48}/wup/testql_cli_generator.py +0 -0
  48. {wup-0.2.46 → wup-0.2.48}/wup/testql_discovery.py +0 -0
  49. {wup-0.2.46 → wup-0.2.48}/wup/testql_monitor.py +0 -0
  50. {wup-0.2.46 → wup-0.2.48}/wup/web_client.py +0 -0
  51. {wup-0.2.46 → wup-0.2.48}/wup.egg-info/dependency_links.txt +0 -0
  52. {wup-0.2.46 → wup-0.2.48}/wup.egg-info/entry_points.txt +0 -0
  53. {wup-0.2.46 → wup-0.2.48}/wup.egg-info/requires.txt +0 -0
  54. {wup-0.2.46 → wup-0.2.48}/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.46
3
+ Version: 0.2.48
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.46-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-$3.47-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-23.0h-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.48-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-$3.35-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-25.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
36
36
 
37
- - 🤖 **LLM usage:** $3.4717 (56 commits)
38
- - 👤 **Human dev:** ~$2304 (23.0h @ $100/h, 30min dedup)
37
+ - 🤖 **LLM usage:** $3.3508 (60 commits)
38
+ - 👤 **Human dev:** ~$2573 (25.7h @ $100/h, 30min dedup)
39
39
 
40
- Generated on 2026-05-23 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
40
+ Generated on 2026-05-24 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.46-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.48-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.46-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-$3.47-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-23.0h-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.48-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-$3.35-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-25.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $3.4717 (56 commits)
10
- - 👤 **Human dev:** ~$2304 (23.0h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $3.3508 (60 commits)
10
+ - 👤 **Human dev:** ~$2573 (25.7h @ $100/h, 30min dedup)
11
11
 
12
- Generated on 2026-05-23 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
12
+ Generated on 2026-05-24 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.46-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.48-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.46"
7
+ version = "0.2.48"
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,11 +1,14 @@
1
1
  import asyncio
2
2
  import json
3
3
  import os
4
+ import signal
4
5
  import tempfile
5
6
  from pathlib import Path
6
7
  from subprocess import CompletedProcess
7
8
  from unittest.mock import Mock
8
9
 
10
+ import pytest
11
+
9
12
  from wup.testql_watcher import TestQLWatcher
10
13
  from wup.models.config import (
11
14
  PlanfileConfig,
@@ -519,3 +522,38 @@ def test_quick_pass_actions_prefer_config_endpoints_for_visual_diff():
519
522
  )
520
523
 
521
524
  assert differ.calls == [("backend", ["http://localhost:8101/api/v3/health"])]
525
+
526
+
527
+ def test_quick_interrupt_does_not_create_failure_track():
528
+ with tempfile.TemporaryDirectory() as tmpdir:
529
+ root = Path(tmpdir)
530
+ scenario_dir = root / "testql-scenarios"
531
+ scenario_dir.mkdir(parents=True, exist_ok=True)
532
+ (scenario_dir / "connect-config-smoke.testql.toon.yaml").write_text("name: smoke\n", encoding="utf-8")
533
+
534
+ cfg = WupConfig(
535
+ project=ProjectConfig(name="demo"),
536
+ services=[ServiceConfig(name="connect-config", paths=["connect-config/**"])],
537
+ watch=WatchConfig(),
538
+ testql=TestQLConfig(scenario_dir="testql-scenarios"),
539
+ )
540
+ watcher = TestQLWatcher(
541
+ project_root=str(root),
542
+ deps_file=str(root / "deps.json"),
543
+ scenarios_dir="testql-scenarios",
544
+ track_dir=".wup/tracks",
545
+ config=cfg,
546
+ )
547
+
548
+ watcher._run_testql = lambda args, timeout: CompletedProcess( # type: ignore[method-assign]
549
+ args=args,
550
+ returncode=-signal.SIGINT,
551
+ stdout="",
552
+ stderr="",
553
+ )
554
+
555
+ with pytest.raises(KeyboardInterrupt):
556
+ asyncio.run(watcher.run_quick_test("connect-config", []))
557
+
558
+ tracks = list((root / ".wup" / "tracks").glob("*.json"))
559
+ assert tracks == []
@@ -0,0 +1,40 @@
1
+ """Regression: visual_diff is skipped during periodic probe cycles by default."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock
6
+
7
+ from wup.models.config import VisualDiffConfig
8
+ from wup.testql_watcher import TestQLWatcher
9
+
10
+
11
+ def _make_watcher(tmp_path, *, run_on_periodic_probe: bool) -> TestQLWatcher:
12
+ watcher = TestQLWatcher.__new__(TestQLWatcher)
13
+ differ = MagicMock()
14
+ differ.cfg = VisualDiffConfig(enabled=True, run_on_periodic_probe=run_on_periodic_probe)
15
+ watcher.visual_differ = differ
16
+ watcher._periodic_probe_in_progress = False
17
+ return watcher
18
+
19
+
20
+ def test_visual_diff_runs_on_file_change_cycles(tmp_path) -> None:
21
+ watcher = _make_watcher(tmp_path, run_on_periodic_probe=False)
22
+ assert watcher._should_run_visual_diff() is True
23
+
24
+
25
+ def test_visual_diff_skipped_on_periodic_probe_by_default(tmp_path) -> None:
26
+ watcher = _make_watcher(tmp_path, run_on_periodic_probe=False)
27
+ watcher._periodic_probe_in_progress = True
28
+ assert watcher._should_run_visual_diff() is False
29
+
30
+
31
+ def test_visual_diff_runs_on_periodic_probe_when_opted_in(tmp_path) -> None:
32
+ watcher = _make_watcher(tmp_path, run_on_periodic_probe=True)
33
+ watcher._periodic_probe_in_progress = True
34
+ assert watcher._should_run_visual_diff() is True
35
+
36
+
37
+ def test_visual_diff_skipped_when_disabled(tmp_path) -> None:
38
+ watcher = _make_watcher(tmp_path, run_on_periodic_probe=True)
39
+ watcher.visual_differ.cfg.enabled = False
40
+ assert watcher._should_run_visual_diff() is False
@@ -0,0 +1,39 @@
1
+ """Regression: visual_diff shows a progress bar for large scans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from wup.models.config import VisualDiffConfig
8
+ from wup.visual_diff import VisualDiffer
9
+
10
+
11
+ def _make_differ(tmp_path) -> VisualDiffer:
12
+ cfg = VisualDiffConfig(
13
+ enabled=True,
14
+ base_url="http://localhost:8100",
15
+ snapshot_dir=str(tmp_path / "snap"),
16
+ diff_dir=str(tmp_path / "diff"),
17
+ pages_from_endpoints=True,
18
+ max_pages=200,
19
+ )
20
+ return VisualDiffer(str(tmp_path), cfg)
21
+
22
+
23
+ def test_progress_returned_for_big_scans(tmp_path, monkeypatch) -> None:
24
+ monkeypatch.delenv("WUP_VISUAL_DIFF_PROGRESS", raising=False)
25
+ differ = _make_differ(tmp_path)
26
+ progress = differ._build_progress("frontend", total=20)
27
+ assert progress is not None
28
+ assert progress.live.transient is True
29
+
30
+
31
+ def test_progress_skipped_for_small_scans(tmp_path) -> None:
32
+ differ = _make_differ(tmp_path)
33
+ assert differ._build_progress("frontend", total=2) is None
34
+
35
+
36
+ def test_progress_can_be_disabled_via_env(tmp_path, monkeypatch) -> None:
37
+ monkeypatch.setenv("WUP_VISUAL_DIFF_PROGRESS", "0")
38
+ differ = _make_differ(tmp_path)
39
+ assert differ._build_progress("frontend", total=50) is None
@@ -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.46"
10
+ __version__ = "0.2.48"
11
11
  __author__ = "Tom Sapletta"
12
12
 
13
13
  from .config import load_config, save_config, get_default_config
@@ -100,6 +100,9 @@ class VisualDiffConfig:
100
100
  "[class*='error'][class*='container']",
101
101
  ])
102
102
  headless: bool = True
103
+ # Run visual_diff during periodic probe cycles too. Default false: visual_diff
104
+ # only runs when something on disk actually changed (or on first cycle).
105
+ run_on_periodic_probe: bool = False
103
106
 
104
107
 
105
108
  @dataclass
@@ -35,7 +35,13 @@ class TestResultEventHandler:
35
35
  )
36
36
  )
37
37
 
38
- self.console.print(f"[red]✗ {event.stage.capitalize()} failed: {event.scenario.name} | track: {event.track_file}[/red]")
38
+ reason = (event.reason or "TestQL failed").strip().splitlines()[-1]
39
+ if len(reason) > 160:
40
+ reason = reason[:157] + "..."
41
+ self.console.print(
42
+ f"[red]✗ {event.stage.capitalize()} failed: {event.scenario.name} — {reason}[/red]\n"
43
+ f"[dim] track: {event.track_file}[/dim]"
44
+ )
39
45
 
40
46
  def handle_test_passed(self, event: ScenarioPassed) -> None:
41
47
  """Handle scenario pass."""
@@ -6,6 +6,7 @@ import asyncio
6
6
  import json
7
7
  import os
8
8
  import re
9
+ import signal
9
10
  import subprocess
10
11
  import time
11
12
  from pathlib import Path
@@ -116,6 +117,7 @@ class TestQLWatcher(WupWatcher):
116
117
  )
117
118
 
118
119
  self._probe_thread = None
120
+ self._periodic_probe_in_progress = False
119
121
  self._normalize_fleet_health_entry()
120
122
 
121
123
  def _normalize_fleet_health_entry(self) -> None:
@@ -405,12 +407,23 @@ class TestQLWatcher(WupWatcher):
405
407
  stderr=f"Error: TestQL command timed out after {timeout} seconds",
406
408
  )
407
409
 
410
+ @staticmethod
411
+ def _is_interrupted_result(result: subprocess.CompletedProcess) -> bool:
412
+ rc = int(result.returncode)
413
+ if rc in {130, 143}:
414
+ return True
415
+ if rc < 0 and (-rc) in {signal.SIGINT, signal.SIGTERM}:
416
+ return True
417
+ return False
418
+
408
419
  def _write_track(self, *, service: str, stage: str, scenario: Optional[Path], result: subprocess.CompletedProcess) -> Path:
409
420
  ts = int(time.time())
410
421
  safe_service = service.replace("/", "_").replace("\\", "_")
411
422
  scenario_name = scenario.name if scenario else "unknown"
412
- stderr_line = (result.stderr or "").strip().splitlines()[:1]
413
- stdout_line = (result.stdout or "").strip().splitlines()[:1]
423
+ stderr_lines = (result.stderr or "").strip().splitlines()
424
+ stdout_lines = (result.stdout or "").strip().splitlines()
425
+ stderr_tail = stderr_lines[-3:] if stderr_lines else []
426
+ stdout_tail = stdout_lines[-5:] if stdout_lines else []
414
427
 
415
428
  payload = {
416
429
  "service": service,
@@ -418,8 +431,11 @@ class TestQLWatcher(WupWatcher):
418
431
  "scenario": str(scenario) if scenario else None,
419
432
  "command": result.args,
420
433
  "returncode": result.returncode,
421
- "stderr_head": stderr_line[0] if stderr_line else "",
422
- "stdout_head": stdout_line[0] if stdout_line else "",
434
+ "stderr_head": stderr_lines[0] if stderr_lines else "",
435
+ "stdout_head": stdout_lines[0] if stdout_lines else "",
436
+ "stderr_tail": stderr_tail,
437
+ "stdout_tail": stdout_tail,
438
+ "failure_summary": self._summarize_testql_failure(result),
423
439
  "track": {
424
440
  "file": str(scenario) if scenario else "",
425
441
  "line": 1,
@@ -465,8 +481,10 @@ class TestQLWatcher(WupWatcher):
465
481
  result = self._run_testql(args, timeout=self._quick_timeout())
466
482
  if result.returncode == 0:
467
483
  return True
484
+ if self._is_interrupted_result(result):
485
+ raise KeyboardInterrupt
468
486
 
469
- reason = result.stderr.strip() or result.stdout.strip() or "Quick TestQL failed"
487
+ reason = self._summarize_testql_failure(result)
470
488
  track_path = self._write_track(service=service, stage="quick",
471
489
  scenario=scenario, result=result)
472
490
 
@@ -485,6 +503,13 @@ class TestQLWatcher(WupWatcher):
485
503
 
486
504
  return False
487
505
 
506
+ def _should_run_visual_diff(self) -> bool:
507
+ if not (self.visual_differ and self.visual_differ.cfg.enabled):
508
+ return False
509
+ if not getattr(self, "_periodic_probe_in_progress", False):
510
+ return True
511
+ return bool(getattr(self.visual_differ.cfg, "run_on_periodic_probe", False))
512
+
488
513
  async def _quick_pass_actions(self, service: str, merged_endpoints: List[str]) -> None:
489
514
  """Actions to perform after all quick scenarios pass."""
490
515
  self._record_health_transition(service=service, status="up", stage="quick",
@@ -492,7 +517,7 @@ class TestQLWatcher(WupWatcher):
492
517
  if self.web_client.is_active:
493
518
  await self.web_client.send_pass(service=service, stage="quick")
494
519
  self.console.print(f"[green]✓ Quick TestQL passed for {service}[/green]")
495
- if self.visual_differ and self.visual_differ.cfg.enabled:
520
+ if self._should_run_visual_diff():
496
521
  visual_endpoints = self._get_config_endpoints_for_service(service) or merged_endpoints
497
522
  visual_results = await self.visual_differ.run_for_service(service, visual_endpoints)
498
523
  visual_issues = [
@@ -593,6 +618,16 @@ class TestQLWatcher(WupWatcher):
593
618
  return stripped
594
619
  return None
595
620
 
621
+ @staticmethod
622
+ def _summarize_testql_failure(result: subprocess.CompletedProcess) -> str:
623
+ """Short failure line for tracks and console (last lines of testql output)."""
624
+ if int(result.returncode) == 124:
625
+ return "TestQL subprocess timed out"
626
+ summary = TestQLWatcher._summarize_health_scenario_failure(result)
627
+ if summary != "health_scenario failed":
628
+ return summary
629
+ return "TestQL command failed"
630
+
596
631
  @staticmethod
597
632
  def _summarize_health_scenario_failure(result: subprocess.CompletedProcess) -> str:
598
633
  """Extract a short human summary from TestQL --output json (avoid trailing '}')."""
@@ -834,15 +869,19 @@ class TestQLWatcher(WupWatcher):
834
869
  if not self.config.services:
835
870
  return
836
871
  self.console.print("[cyan]⟳ Periodic live probe cycle[/cyan]")
872
+ self._periodic_probe_in_progress = True
837
873
  try:
838
- asyncio.run(self._run_fleet_health_scenario())
839
- except Exception as exc: # noqa: BLE001
840
- self.console.print(f"[red]Fleet health scenario error: {exc}[/red]")
841
- for svc in self.config.services:
842
874
  try:
843
- asyncio.run(self.run_quick_test(svc.name, []))
875
+ asyncio.run(self._run_fleet_health_scenario())
844
876
  except Exception as exc: # noqa: BLE001
845
- self.console.print(f"[red]Probe error for {svc.name}: {exc}[/red]")
877
+ self.console.print(f"[red]Fleet health scenario error: {exc}[/red]")
878
+ for svc in self.config.services:
879
+ try:
880
+ asyncio.run(self.run_quick_test(svc.name, []))
881
+ except Exception as exc: # noqa: BLE001
882
+ self.console.print(f"[red]Probe error for {svc.name}: {exc}[/red]")
883
+ finally:
884
+ self._periodic_probe_in_progress = False
846
885
 
847
886
  def _start_periodic_probe_thread(self) -> None:
848
887
  import threading
@@ -24,6 +24,14 @@ from typing import Any, Dict, List, Optional, Tuple
24
24
  from urllib.parse import urlparse
25
25
 
26
26
  from rich.console import Console
27
+ from rich.progress import (
28
+ BarColumn,
29
+ MofNCompleteColumn,
30
+ Progress,
31
+ TextColumn,
32
+ TimeElapsedColumn,
33
+ TimeRemainingColumn,
34
+ )
27
35
 
28
36
  from .models.config import VisualDiffConfig
29
37
 
@@ -442,14 +450,44 @@ class VisualDiffer:
442
450
  new_urls: List[str] = []
443
451
  error_results: List[Tuple[str, str]] = []
444
452
 
445
- for url in pages:
446
- result = await self._check_page(service, url)
447
- results.append(result)
448
- self._categorize_page_result(service, url, result, ok_urls, new_urls, error_results)
453
+ progress = self._build_progress(service, len(pages))
454
+ if progress is None:
455
+ for url in pages:
456
+ result = await self._check_page(service, url)
457
+ results.append(result)
458
+ self._categorize_page_result(service, url, result, ok_urls, new_urls, error_results)
459
+ else:
460
+ with progress:
461
+ task_id = progress.add_task(
462
+ f"[cyan]🔍 Visual diff {service}", total=len(pages), url=""
463
+ )
464
+ for url in pages:
465
+ progress.update(task_id, url=_short_url(url))
466
+ result = await self._check_page(service, url)
467
+ results.append(result)
468
+ self._categorize_page_result(service, url, result, ok_urls, new_urls, error_results)
469
+ progress.advance(task_id)
449
470
 
450
471
  self._print_scan_summary(service, ok_urls, new_urls, error_results)
451
472
  return results
452
473
 
474
+ def _build_progress(self, service: str, total: int) -> Optional[Progress]:
475
+ """Return a rich Progress for big scans; None for tiny ones (avoid noise)."""
476
+ if total < 5 or os.environ.get("WUP_VISUAL_DIFF_PROGRESS", "1") == "0":
477
+ return None
478
+ return Progress(
479
+ TextColumn("[bold blue]{task.description}"),
480
+ BarColumn(bar_width=None),
481
+ MofNCompleteColumn(),
482
+ TextColumn("[dim]{task.fields[url]}"),
483
+ TimeElapsedColumn(),
484
+ TextColumn("eta"),
485
+ TimeRemainingColumn(),
486
+ console=console,
487
+ transient=True,
488
+ refresh_per_second=4,
489
+ )
490
+
453
491
  async def _check_page(self, service: str, url: str) -> Dict[str, Any]:
454
492
  snap_path = _snapshot_path(self.snapshot_dir, service, url)
455
493
  old_snapshot = _load_snapshot(snap_path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wup
3
- Version: 0.2.46
3
+ Version: 0.2.48
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.46-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-$3.47-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-23.0h-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.48-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-$3.35-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-25.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
36
36
 
37
- - 🤖 **LLM usage:** $3.4717 (56 commits)
38
- - 👤 **Human dev:** ~$2304 (23.0h @ $100/h, 30min dedup)
37
+ - 🤖 **LLM usage:** $3.3508 (60 commits)
38
+ - 👤 **Human dev:** ~$2573 (25.7h @ $100/h, 30min dedup)
39
39
 
40
- Generated on 2026-05-23 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
40
+ Generated on 2026-05-24 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.46-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.48-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
 
@@ -8,6 +8,8 @@ tests/test_monitoring_manifest.py
8
8
  tests/test_service_inference.py
9
9
  tests/test_testql_monitor.py
10
10
  tests/test_testql_watcher.py
11
+ tests/test_visual_diff_periodic_skip.py
12
+ tests/test_visual_diff_progress.py
11
13
  tests/test_web_client.py
12
14
  tests/test_wup.py
13
15
  wup/__init__.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes