wup 0.2.47__tar.gz → 0.2.49__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.47/wup.egg-info → wup-0.2.49}/PKG-INFO +26 -6
  2. {wup-0.2.47 → wup-0.2.49}/README.md +25 -5
  3. {wup-0.2.47 → wup-0.2.49}/pyproject.toml +1 -1
  4. wup-0.2.49/tests/test_visual_diff_periodic_skip.py +40 -0
  5. wup-0.2.49/tests/test_visual_diff_progress.py +39 -0
  6. {wup-0.2.47 → wup-0.2.49}/wup/__init__.py +1 -1
  7. {wup-0.2.47 → wup-0.2.49}/wup/config.py +51 -42
  8. {wup-0.2.47 → wup-0.2.49}/wup/models/config.py +3 -0
  9. {wup-0.2.47 → wup-0.2.49}/wup/testing/handlers/event_handlers.py +7 -1
  10. {wup-0.2.47 → wup-0.2.49}/wup/testql_watcher.py +69 -39
  11. {wup-0.2.47 → wup-0.2.49}/wup/visual_diff.py +42 -4
  12. {wup-0.2.47 → wup-0.2.49/wup.egg-info}/PKG-INFO +26 -6
  13. {wup-0.2.47 → wup-0.2.49}/wup.egg-info/SOURCES.txt +2 -0
  14. {wup-0.2.47 → wup-0.2.49}/LICENSE +0 -0
  15. {wup-0.2.47 → wup-0.2.49}/setup.cfg +0 -0
  16. {wup-0.2.47 → wup-0.2.49}/tests/test_auto_detection.py +0 -0
  17. {wup-0.2.47 → wup-0.2.49}/tests/test_cli_filtering.py +0 -0
  18. {wup-0.2.47 → wup-0.2.49}/tests/test_e2e.py +0 -0
  19. {wup-0.2.47 → wup-0.2.49}/tests/test_monitoring_manifest.py +0 -0
  20. {wup-0.2.47 → wup-0.2.49}/tests/test_service_inference.py +0 -0
  21. {wup-0.2.47 → wup-0.2.49}/tests/test_testql_monitor.py +0 -0
  22. {wup-0.2.47 → wup-0.2.49}/tests/test_testql_watcher.py +0 -0
  23. {wup-0.2.47 → wup-0.2.49}/tests/test_web_client.py +0 -0
  24. {wup-0.2.47 → wup-0.2.49}/tests/test_wup.py +0 -0
  25. {wup-0.2.47 → wup-0.2.49}/wup/_ast_detector.py +0 -0
  26. {wup-0.2.47 → wup-0.2.49}/wup/_base_detector.py +0 -0
  27. {wup-0.2.47 → wup-0.2.49}/wup/_hash_detector.py +0 -0
  28. {wup-0.2.47 → wup-0.2.49}/wup/_yaml_detector.py +0 -0
  29. {wup-0.2.47 → wup-0.2.49}/wup/anomaly_detector.py +0 -0
  30. {wup-0.2.47 → wup-0.2.49}/wup/anomaly_models.py +0 -0
  31. {wup-0.2.47 → wup-0.2.49}/wup/assistant.py +0 -0
  32. {wup-0.2.47 → wup-0.2.49}/wup/bus.py +0 -0
  33. {wup-0.2.47 → wup-0.2.49}/wup/cli.py +0 -0
  34. {wup-0.2.47 → wup-0.2.49}/wup/cli_config_generator.py +0 -0
  35. {wup-0.2.47 → wup-0.2.49}/wup/cli_scanner.py +0 -0
  36. {wup-0.2.47 → wup-0.2.49}/wup/core.py +0 -0
  37. {wup-0.2.47 → wup-0.2.49}/wup/dependency_mapper.py +0 -0
  38. {wup-0.2.47 → wup-0.2.49}/wup/event_store.py +0 -0
  39. {wup-0.2.47 → wup-0.2.49}/wup/file_watcher/events/file_events.py +0 -0
  40. {wup-0.2.47 → wup-0.2.49}/wup/models/__init__.py +0 -0
  41. {wup-0.2.47 → wup-0.2.49}/wup/monitoring_manifest.py +0 -0
  42. {wup-0.2.47 → wup-0.2.49}/wup/planfile_reporter.py +0 -0
  43. {wup-0.2.47 → wup-0.2.49}/wup/testing/events/health_events.py +0 -0
  44. {wup-0.2.47 → wup-0.2.49}/wup/testing/events/test_results.py +0 -0
  45. {wup-0.2.47 → wup-0.2.49}/wup/testing/handlers/health_handlers.py +0 -0
  46. {wup-0.2.47 → wup-0.2.49}/wup/testing/queries/health_queries.py +0 -0
  47. {wup-0.2.47 → wup-0.2.49}/wup/testql_cli_generator.py +0 -0
  48. {wup-0.2.47 → wup-0.2.49}/wup/testql_discovery.py +0 -0
  49. {wup-0.2.47 → wup-0.2.49}/wup/testql_monitor.py +0 -0
  50. {wup-0.2.47 → wup-0.2.49}/wup/web_client.py +0 -0
  51. {wup-0.2.47 → wup-0.2.49}/wup.egg-info/dependency_links.txt +0 -0
  52. {wup-0.2.47 → wup-0.2.49}/wup.egg-info/entry_points.txt +0 -0
  53. {wup-0.2.47 → wup-0.2.49}/wup.egg-info/requires.txt +0 -0
  54. {wup-0.2.47 → wup-0.2.49}/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.47
3
+ Version: 0.2.49
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.47-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.49-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.12-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.4708 (57 commits)
38
- - 👤 **Human dev:** ~$2304 (23.0h @ $100/h, 30min dedup)
37
+ - 🤖 **LLM usage:** $3.1175 (62 commits)
38
+ - 👤 **Human dev:** ~$2574 (25.7h @ $100/h, 30min dedup)
39
39
 
40
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.47-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.49-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
 
@@ -512,6 +512,26 @@ python3 -m pytest tests/test_testql_watcher.py -v
512
512
  python3 -m pytest tests/ --cov=wup
513
513
  ```
514
514
 
515
+ ### Goal wrapper (local `.venv`)
516
+
517
+ When `goal` is installed globally, it may inherit another project's `VIRTUAL_ENV`.
518
+ Use the local wrapper to force `wup/.venv` before running `goal` commands:
519
+
520
+ ```bash
521
+ # Default: runs goal -a
522
+ bash scripts/goal-local
523
+
524
+ # Explicit arguments
525
+ bash scripts/goal-local -a
526
+ bash scripts/goal-local --dry-run
527
+ ```
528
+
529
+ If needed, point to a specific `goal` binary:
530
+
531
+ ```bash
532
+ GOAL_BIN=/home/tom/.local/bin/goal bash scripts/goal-local -a
533
+ ```
534
+
515
535
  ### Examples
516
536
 
517
537
  ```bash
@@ -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.47-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.49-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.12-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.4708 (57 commits)
10
- - 👤 **Human dev:** ~$2304 (23.0h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $3.1175 (62 commits)
10
+ - 👤 **Human dev:** ~$2574 (25.7h @ $100/h, 30min dedup)
11
11
 
12
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.47-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.49-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
 
@@ -484,6 +484,26 @@ python3 -m pytest tests/test_testql_watcher.py -v
484
484
  python3 -m pytest tests/ --cov=wup
485
485
  ```
486
486
 
487
+ ### Goal wrapper (local `.venv`)
488
+
489
+ When `goal` is installed globally, it may inherit another project's `VIRTUAL_ENV`.
490
+ Use the local wrapper to force `wup/.venv` before running `goal` commands:
491
+
492
+ ```bash
493
+ # Default: runs goal -a
494
+ bash scripts/goal-local
495
+
496
+ # Explicit arguments
497
+ bash scripts/goal-local -a
498
+ bash scripts/goal-local --dry-run
499
+ ```
500
+
501
+ If needed, point to a specific `goal` binary:
502
+
503
+ ```bash
504
+ GOAL_BIN=/home/tom/.local/bin/goal bash scripts/goal-local -a
505
+ ```
506
+
487
507
  ### Examples
488
508
 
489
509
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wup"
7
- version = "0.2.47"
7
+ version = "0.2.49"
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"
@@ -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.47"
10
+ __version__ = "0.2.49"
11
11
  __author__ = "Tom Sapletta"
12
12
 
13
13
  from .config import load_config, save_config, get_default_config
@@ -6,7 +6,7 @@ Handles loading and validation of wup.yaml configuration files.
6
6
 
7
7
  import os
8
8
  from pathlib import Path
9
- from typing import Optional
9
+ from typing import Optional, List
10
10
 
11
11
  import yaml
12
12
 
@@ -98,38 +98,27 @@ def load_config(project_root: Path, config_path: Optional[Path] = None) -> WupCo
98
98
  return validate_config(raw_config)
99
99
 
100
100
 
101
- def validate_config(raw: dict) -> WupConfig:
102
- """
103
- Validate raw config dict and convert to WupConfig object.
104
-
105
- Args:
106
- raw: Raw configuration dictionary from YAML
107
-
108
- Returns:
109
- Validated WupConfig object
110
-
111
- Raises:
112
- ValueError: If config is invalid
113
- """
114
- # Parse project config
101
+ def _parse_project_config(raw: dict) -> ProjectConfig:
115
102
  project_raw = raw.get("project", {})
116
103
  if not project_raw.get("name"):
117
104
  raise ValueError("Config must contain project.name")
118
105
 
119
- project = ProjectConfig(
106
+ return ProjectConfig(
120
107
  name=project_raw["name"],
121
108
  description=project_raw.get("description", "")
122
109
  )
123
-
124
- # Parse watch config
110
+
111
+
112
+ def _parse_watch_config(raw: dict) -> WatchConfig:
125
113
  watch_raw = raw.get("watch", {})
126
- watch = WatchConfig(
114
+ return WatchConfig(
127
115
  paths=watch_raw.get("paths", []),
128
116
  exclude_patterns=watch_raw.get("exclude_patterns", ["*.md", "*.txt"]),
129
117
  file_types=watch_raw.get("file_types", [])
130
118
  )
131
-
132
- # Parse services
119
+
120
+
121
+ def _parse_services_config(raw: dict) -> List[ServiceConfig]:
133
122
  services_raw = raw.get("services", [])
134
123
  services = []
135
124
  for svc_raw in services_raw:
@@ -161,18 +150,20 @@ def validate_config(raw: dict) -> WupConfig:
161
150
  )
162
151
  )
163
152
  services.append(service)
164
-
165
- # Parse test strategy
153
+ return services
154
+
155
+
156
+ def _parse_strategy_config(raw: dict) -> TestStrategyConfig:
166
157
  strategy_raw = raw.get("test_strategy", {})
167
- test_strategy = TestStrategyConfig(
158
+ return TestStrategyConfig(
168
159
  quick=strategy_raw.get("quick", {"debounce_s": 2, "max_queue": 5, "timeout_s": 10}),
169
160
  detail=strategy_raw.get("detail", {"debounce_s": 10, "max_queue": 1, "timeout_s": 30})
170
161
  )
171
-
172
- # Parse testql config
162
+
163
+
164
+ def _parse_testql_config(raw: dict) -> TestQLConfig:
173
165
  testql_raw = raw.get("testql", {})
174
166
  extra_args_raw = testql_raw.get("extra_args", ["--timeout", "10"])
175
- extra_args = []
176
167
  if isinstance(extra_args_raw, str):
177
168
  extra_args_raw = extra_args_raw.split()
178
169
  elif isinstance(extra_args_raw, list):
@@ -215,7 +206,7 @@ def validate_config(raw: dict) -> WupConfig:
215
206
  normalized_extra_args.append(arg)
216
207
  i += 1
217
208
 
218
- testql = TestQLConfig(
209
+ return TestQLConfig(
219
210
  scenario_dir=testql_raw.get("scenario_dir", "scenarios/tests"),
220
211
  smoke_scenario=testql_raw.get("smoke_scenario", "smoke.testql.toon.yaml"),
221
212
  output_format=testql_raw.get("output_format", "json"),
@@ -233,7 +224,8 @@ def validate_config(raw: dict) -> WupConfig:
233
224
  endpoints_by_service=testql_raw.get("endpoints_by_service", {})
234
225
  )
235
226
 
236
- # Parse visual_diff config
227
+
228
+ def _parse_visual_diff_config(raw: dict) -> VisualDiffConfig:
237
229
  vd_raw = raw.get("visual_diff", {})
238
230
  env_visual_enabled = os.environ.get("WUP_VISUAL_DIFF_ENABLED")
239
231
  env_visual_delay = os.environ.get("WUP_VISUAL_DIFF_DELAY_SECONDS")
@@ -268,7 +260,7 @@ def validate_config(raw: dict) -> WupConfig:
268
260
  env_visual_pages_from_endpoints.strip().lower() in {"1", "true", "yes", "on"}
269
261
  )
270
262
 
271
- visual_diff = VisualDiffConfig(
263
+ return VisualDiffConfig(
272
264
  enabled=visual_enabled,
273
265
  base_url=vd_raw.get("base_url", ""),
274
266
  base_url_env=vd_raw.get("base_url_env", "WUP_BASE_URL"),
@@ -291,11 +283,13 @@ def validate_config(raw: dict) -> WupConfig:
291
283
  "[class*='error'][class*='container']",
292
284
  ]),
293
285
  headless=vd_raw.get("headless", True),
286
+ run_on_periodic_probe=bool(vd_raw.get("run_on_periodic_probe", False)),
294
287
  )
295
288
 
296
- # Parse web config (event sink)
289
+
290
+ def _parse_web_config(raw: dict) -> WebConfig:
297
291
  web_raw = raw.get("web", {})
298
- web = WebConfig(
292
+ return WebConfig(
299
293
  enabled=web_raw.get("enabled", False),
300
294
  endpoint=web_raw.get("endpoint", ""),
301
295
  endpoint_env=web_raw.get("endpoint_env", "WUPBRO_ENDPOINT"),
@@ -303,7 +297,8 @@ def validate_config(raw: dict) -> WupConfig:
303
297
  api_key=web_raw.get("api_key", ""),
304
298
  )
305
299
 
306
- # Parse planfile config (ticket sink for Koru/Planfile workflows)
300
+
301
+ def _parse_planfile_config(raw: dict) -> PlanfileConfig:
307
302
  planfile_raw = raw.get("planfile", {})
308
303
  env_planfile_enabled = os.environ.get("WUP_PLANFILE_ENABLED")
309
304
  if env_planfile_enabled is None:
@@ -313,7 +308,7 @@ def validate_config(raw: dict) -> WupConfig:
313
308
 
314
309
  labels_raw = planfile_raw.get("labels", ["koru", "llm-ready", "wup", "auto-diag"])
315
310
  labels = [str(label) for label in labels_raw] if isinstance(labels_raw, list) else []
316
- planfile = PlanfileConfig(
311
+ return PlanfileConfig(
317
312
  enabled=planfile_enabled,
318
313
  command=planfile_raw.get("command", "planfile"),
319
314
  sprint=planfile_raw.get("sprint", "current"),
@@ -323,15 +318,29 @@ def validate_config(raw: dict) -> WupConfig:
323
318
  labels=labels or ["koru", "llm-ready", "wup", "auto-diag"],
324
319
  )
325
320
 
321
+
322
+ def validate_config(raw: dict) -> WupConfig:
323
+ """
324
+ Validate raw config dict and convert to WupConfig object.
325
+
326
+ Args:
327
+ raw: Raw configuration dictionary from YAML
328
+
329
+ Returns:
330
+ Validated WupConfig object
331
+
332
+ Raises:
333
+ ValueError: If config is invalid
334
+ """
326
335
  return WupConfig(
327
- project=project,
328
- watch=watch,
329
- services=services,
330
- test_strategy=test_strategy,
331
- testql=testql,
332
- visual_diff=visual_diff,
333
- web=web,
334
- planfile=planfile,
336
+ project=_parse_project_config(raw),
337
+ watch=_parse_watch_config(raw),
338
+ services=_parse_services_config(raw),
339
+ test_strategy=_parse_strategy_config(raw),
340
+ testql=_parse_testql_config(raw),
341
+ visual_diff=_parse_visual_diff_config(raw),
342
+ web=_parse_web_config(raw),
343
+ planfile=_parse_planfile_config(raw),
335
344
  )
336
345
 
337
346
 
@@ -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."""
@@ -117,6 +117,7 @@ class TestQLWatcher(WupWatcher):
117
117
  )
118
118
 
119
119
  self._probe_thread = None
120
+ self._periodic_probe_in_progress = False
120
121
  self._normalize_fleet_health_entry()
121
122
 
122
123
  def _normalize_fleet_health_entry(self) -> None:
@@ -419,8 +420,10 @@ class TestQLWatcher(WupWatcher):
419
420
  ts = int(time.time())
420
421
  safe_service = service.replace("/", "_").replace("\\", "_")
421
422
  scenario_name = scenario.name if scenario else "unknown"
422
- stderr_line = (result.stderr or "").strip().splitlines()[:1]
423
- 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 []
424
427
 
425
428
  payload = {
426
429
  "service": service,
@@ -428,8 +431,11 @@ class TestQLWatcher(WupWatcher):
428
431
  "scenario": str(scenario) if scenario else None,
429
432
  "command": result.args,
430
433
  "returncode": result.returncode,
431
- "stderr_head": stderr_line[0] if stderr_line else "",
432
- "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),
433
439
  "track": {
434
440
  "file": str(scenario) if scenario else "",
435
441
  "line": 1,
@@ -478,7 +484,7 @@ class TestQLWatcher(WupWatcher):
478
484
  if self._is_interrupted_result(result):
479
485
  raise KeyboardInterrupt
480
486
 
481
- reason = result.stderr.strip() or result.stdout.strip() or "Quick TestQL failed"
487
+ reason = self._summarize_testql_failure(result)
482
488
  track_path = self._write_track(service=service, stage="quick",
483
489
  scenario=scenario, result=result)
484
490
 
@@ -497,6 +503,13 @@ class TestQLWatcher(WupWatcher):
497
503
 
498
504
  return False
499
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
+
500
513
  async def _quick_pass_actions(self, service: str, merged_endpoints: List[str]) -> None:
501
514
  """Actions to perform after all quick scenarios pass."""
502
515
  self._record_health_transition(service=service, status="up", stage="quick",
@@ -504,7 +517,7 @@ class TestQLWatcher(WupWatcher):
504
517
  if self.web_client.is_active:
505
518
  await self.web_client.send_pass(service=service, stage="quick")
506
519
  self.console.print(f"[green]✓ Quick TestQL passed for {service}[/green]")
507
- if self.visual_differ and self.visual_differ.cfg.enabled:
520
+ if self._should_run_visual_diff():
508
521
  visual_endpoints = self._get_config_endpoints_for_service(service) or merged_endpoints
509
522
  visual_results = await self.visual_differ.run_for_service(service, visual_endpoints)
510
523
  visual_issues = [
@@ -605,6 +618,16 @@ class TestQLWatcher(WupWatcher):
605
618
  return stripped
606
619
  return None
607
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
+
608
631
  @staticmethod
609
632
  def _summarize_health_scenario_failure(result: subprocess.CompletedProcess) -> str:
610
633
  """Extract a short human summary from TestQL --output json (avoid trailing '}')."""
@@ -673,6 +696,35 @@ class TestQLWatcher(WupWatcher):
673
696
  )
674
697
  return True
675
698
 
699
+ async def _run_quick_test_no_scenarios(self, service: str, merged_endpoints: List[str]) -> bool:
700
+ self.console.print(
701
+ f"[yellow]⚠ No TestQL scenarios found for {service} — running visual diff only[/yellow]"
702
+ )
703
+ self._record_health_transition(service=service, status="up", stage="quick", message="Probes passed (no scenarios)")
704
+ if self.web_client.is_active:
705
+ await self.web_client.send_pass(service=service, stage="quick")
706
+ if self.visual_differ and self.visual_differ.cfg.enabled:
707
+ visual_endpoints = self._get_config_endpoints_for_service(service) or merged_endpoints
708
+ visual_results = await self.visual_differ.run_for_service(service, visual_endpoints)
709
+ visual_issues = [
710
+ item for item in visual_results
711
+ if item.get("diff", {}).get("status") == "issue"
712
+ ]
713
+ if visual_issues:
714
+ issue_text = "; ".join(
715
+ ", ".join(item.get("diff", {}).get("issues", []) or ["visual page issue"])
716
+ for item in visual_issues
717
+ )
718
+ self._record_health_transition(
719
+ service=service,
720
+ status="down",
721
+ stage="visual",
722
+ message=issue_text or "visual page issue",
723
+ track_file="",
724
+ )
725
+ await self._publish_visual_events(service, visual_results)
726
+ return True
727
+
676
728
  async def run_quick_test(self, service: str, endpoints: List[str]) -> bool:
677
729
  merged_endpoints = self._merge_endpoints(service, endpoints)
678
730
 
@@ -686,33 +738,7 @@ class TestQLWatcher(WupWatcher):
686
738
  scenarios = scenarios[:limit]
687
739
 
688
740
  if not scenarios:
689
- self.console.print(
690
- f"[yellow]⚠ No TestQL scenarios found for {service} — running visual diff only[/yellow]"
691
- )
692
- self._record_health_transition(service=service, status="up", stage="quick", message="Probes passed (no scenarios)")
693
- if self.web_client.is_active:
694
- await self.web_client.send_pass(service=service, stage="quick")
695
- if self.visual_differ and self.visual_differ.cfg.enabled:
696
- visual_endpoints = self._get_config_endpoints_for_service(service) or merged_endpoints
697
- visual_results = await self.visual_differ.run_for_service(service, visual_endpoints)
698
- visual_issues = [
699
- item for item in visual_results
700
- if item.get("diff", {}).get("status") == "issue"
701
- ]
702
- if visual_issues:
703
- issue_text = "; ".join(
704
- ", ".join(item.get("diff", {}).get("issues", []) or ["visual page issue"])
705
- for item in visual_issues
706
- )
707
- self._record_health_transition(
708
- service=service,
709
- status="down",
710
- stage="visual",
711
- message=issue_text or "visual page issue",
712
- track_file="",
713
- )
714
- await self._publish_visual_events(service, visual_results)
715
- return True
741
+ return await self._run_quick_test_no_scenarios(service, merged_endpoints)
716
742
 
717
743
  self.console.print(
718
744
  f"[cyan]🧪 Quick TestQL for {service} "
@@ -846,15 +872,19 @@ class TestQLWatcher(WupWatcher):
846
872
  if not self.config.services:
847
873
  return
848
874
  self.console.print("[cyan]⟳ Periodic live probe cycle[/cyan]")
875
+ self._periodic_probe_in_progress = True
849
876
  try:
850
- asyncio.run(self._run_fleet_health_scenario())
851
- except Exception as exc: # noqa: BLE001
852
- self.console.print(f"[red]Fleet health scenario error: {exc}[/red]")
853
- for svc in self.config.services:
854
877
  try:
855
- asyncio.run(self.run_quick_test(svc.name, []))
878
+ asyncio.run(self._run_fleet_health_scenario())
856
879
  except Exception as exc: # noqa: BLE001
857
- self.console.print(f"[red]Probe error for {svc.name}: {exc}[/red]")
880
+ self.console.print(f"[red]Fleet health scenario error: {exc}[/red]")
881
+ for svc in self.config.services:
882
+ try:
883
+ asyncio.run(self.run_quick_test(svc.name, []))
884
+ except Exception as exc: # noqa: BLE001
885
+ self.console.print(f"[red]Probe error for {svc.name}: {exc}[/red]")
886
+ finally:
887
+ self._periodic_probe_in_progress = False
858
888
 
859
889
  def _start_periodic_probe_thread(self) -> None:
860
890
  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.47
3
+ Version: 0.2.49
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.47-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.49-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.12-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.4708 (57 commits)
38
- - 👤 **Human dev:** ~$2304 (23.0h @ $100/h, 30min dedup)
37
+ - 🤖 **LLM usage:** $3.1175 (62 commits)
38
+ - 👤 **Human dev:** ~$2574 (25.7h @ $100/h, 30min dedup)
39
39
 
40
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.47-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.49-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
 
@@ -512,6 +512,26 @@ python3 -m pytest tests/test_testql_watcher.py -v
512
512
  python3 -m pytest tests/ --cov=wup
513
513
  ```
514
514
 
515
+ ### Goal wrapper (local `.venv`)
516
+
517
+ When `goal` is installed globally, it may inherit another project's `VIRTUAL_ENV`.
518
+ Use the local wrapper to force `wup/.venv` before running `goal` commands:
519
+
520
+ ```bash
521
+ # Default: runs goal -a
522
+ bash scripts/goal-local
523
+
524
+ # Explicit arguments
525
+ bash scripts/goal-local -a
526
+ bash scripts/goal-local --dry-run
527
+ ```
528
+
529
+ If needed, point to a specific `goal` binary:
530
+
531
+ ```bash
532
+ GOAL_BIN=/home/tom/.local/bin/goal bash scripts/goal-local -a
533
+ ```
534
+
515
535
  ### Examples
516
536
 
517
537
  ```bash
@@ -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