wup 0.2.29__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.29/wup.egg-info → wup-0.2.33}/PKG-INFO +9 -7
  2. {wup-0.2.29 → wup-0.2.33}/README.md +6 -6
  3. {wup-0.2.29 → wup-0.2.33}/pyproject.toml +6 -1
  4. {wup-0.2.29 → wup-0.2.33}/tests/test_testql_watcher.py +86 -4
  5. {wup-0.2.29 → wup-0.2.33}/tests/test_wup.py +138 -6
  6. {wup-0.2.29 → wup-0.2.33}/wup/__init__.py +1 -1
  7. {wup-0.2.29 → wup-0.2.33}/wup/cli.py +115 -53
  8. {wup-0.2.29 → wup-0.2.33}/wup/config.py +31 -17
  9. {wup-0.2.29 → wup-0.2.33}/wup/core.py +29 -14
  10. {wup-0.2.29 → wup-0.2.33}/wup/monitoring_manifest.py +93 -59
  11. {wup-0.2.29 → wup-0.2.33}/wup/testql_monitor.py +131 -68
  12. {wup-0.2.29 → wup-0.2.33}/wup/testql_watcher.py +62 -27
  13. {wup-0.2.29 → wup-0.2.33}/wup/visual_diff.py +140 -33
  14. {wup-0.2.29 → wup-0.2.33/wup.egg-info}/PKG-INFO +9 -7
  15. {wup-0.2.29 → wup-0.2.33}/wup.egg-info/requires.txt +3 -0
  16. {wup-0.2.29 → wup-0.2.33}/LICENSE +0 -0
  17. {wup-0.2.29 → wup-0.2.33}/setup.cfg +0 -0
  18. {wup-0.2.29 → wup-0.2.33}/tests/test_e2e.py +0 -0
  19. {wup-0.2.29 → wup-0.2.33}/tests/test_monitoring_manifest.py +0 -0
  20. {wup-0.2.29 → wup-0.2.33}/tests/test_testql_monitor.py +0 -0
  21. {wup-0.2.29 → wup-0.2.33}/tests/test_web_client.py +0 -0
  22. {wup-0.2.29 → wup-0.2.33}/wup/_ast_detector.py +0 -0
  23. {wup-0.2.29 → wup-0.2.33}/wup/_hash_detector.py +0 -0
  24. {wup-0.2.29 → wup-0.2.33}/wup/_yaml_detector.py +0 -0
  25. {wup-0.2.29 → wup-0.2.33}/wup/anomaly_detector.py +0 -0
  26. {wup-0.2.29 → wup-0.2.33}/wup/anomaly_models.py +0 -0
  27. {wup-0.2.29 → wup-0.2.33}/wup/assistant.py +0 -0
  28. {wup-0.2.29 → wup-0.2.33}/wup/dependency_mapper.py +0 -0
  29. {wup-0.2.29 → wup-0.2.33}/wup/models/__init__.py +0 -0
  30. {wup-0.2.29 → wup-0.2.33}/wup/models/config.py +0 -0
  31. {wup-0.2.29 → wup-0.2.33}/wup/testql_discovery.py +0 -0
  32. {wup-0.2.29 → wup-0.2.33}/wup/web_client.py +0 -0
  33. {wup-0.2.29 → wup-0.2.33}/wup.egg-info/SOURCES.txt +0 -0
  34. {wup-0.2.29 → wup-0.2.33}/wup.egg-info/dependency_links.txt +0 -0
  35. {wup-0.2.29 → wup-0.2.33}/wup.egg-info/entry_points.txt +0 -0
  36. {wup-0.2.29 → 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.29
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
@@ -22,6 +22,8 @@ Requires-Dist: psutil>=5.9.0
22
22
  Requires-Dist: rich>=13.0.0
23
23
  Requires-Dist: typer>=0.9.0
24
24
  Requires-Dist: pyyaml>=6.0
25
+ Provides-Extra: visual
26
+ Requires-Dist: playwright<2,>=1.40; extra == "visual"
25
27
  Dynamic: license-file
26
28
 
27
29
  # WUP (What's Up)
@@ -29,17 +31,17 @@ Dynamic: license-file
29
31
 
30
32
  ## AI Cost Tracking
31
33
 
32
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.29-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
33
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.28-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-17.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)
34
36
 
35
- - 🤖 **LLM usage:** $2.2757 (39 commits)
36
- - 👤 **Human dev:** ~$1732 (17.3h @ $100/h, 30min dedup)
37
+ - 🤖 **LLM usage:** $2.5582 (43 commits)
38
+ - 👤 **Human dev:** ~$1832 (18.3h @ $100/h, 30min dedup)
37
39
 
38
- Generated on 2026-05-17 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
40
+ Generated on 2026-05-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
39
41
 
40
42
  ---
41
43
 
42
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.29-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)
43
45
 
44
46
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
45
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.29-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.28-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-17.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.2757 (39 commits)
10
- - 👤 **Human dev:** ~$1732 (17.3h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $2.5582 (43 commits)
10
+ - 👤 **Human dev:** ~$1832 (18.3h @ $100/h, 30min dedup)
11
11
 
12
- Generated on 2026-05-17 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
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.29-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.29"
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"
@@ -30,6 +30,11 @@ classifiers = [
30
30
  ]
31
31
  license = "Apache-2.0"
32
32
 
33
+ [project.optional-dependencies]
34
+ visual = [
35
+ "playwright>=1.40,<2",
36
+ ]
37
+
33
38
  [tool.setuptools.packages.find]
34
39
  include = ["wup*"]
35
40
 
@@ -9,6 +9,7 @@ from wup.testql_watcher import TestQLWatcher
9
9
  from wup.models.config import (
10
10
  ProjectConfig,
11
11
  ServiceConfig,
12
+ TestStrategyConfig,
12
13
  TestQLConfig,
13
14
  VisualDiffConfig,
14
15
  WatchConfig,
@@ -25,14 +26,15 @@ def test_process_changed_file_creates_track_on_failure():
25
26
 
26
27
  scenario_dir = root / "testql-scenarios"
27
28
  scenario_dir.mkdir(parents=True, exist_ok=True)
28
- failing_scenario = scenario_dir / "app-users.testql.toon.yaml"
29
+ failing_scenario = scenario_dir / "api-users-smoke.testql.toon.yaml"
29
30
  failing_scenario.write_text("name: failing\n", encoding="utf-8")
30
31
 
31
- # Pass empty config to prevent loading from temp dir
32
- from wup.models.config import TestQLConfig, WatchConfig
32
+ # Pass config with service to prevent loading from temp dir
33
+ from wup.models.config import TestQLConfig, WatchConfig, ServiceConfig
34
+ service_config = ServiceConfig(name="app/users", paths=["app/users"])
33
35
  empty_config = WupConfig(
34
36
  project=ProjectConfig(name="test"),
35
- services=[],
37
+ services=[service_config],
36
38
  test_strategy=None,
37
39
  watch=WatchConfig(), # Add watch config to avoid file filtering issues
38
40
  testql=TestQLConfig(scenario_dir="testql-scenarios")
@@ -53,6 +55,9 @@ def test_process_changed_file_creates_track_on_failure():
53
55
  return CompletedProcess(args=args, returncode=0, stdout="{}", stderr="")
54
56
 
55
57
  watcher._run_testql = fake_run_testql # type: ignore[method-assign]
58
+
59
+ # Mock scenario selection to return our failing scenario
60
+ watcher._select_scenarios_for_service = lambda service: [failing_scenario] # type: ignore[method-assign]
56
61
 
57
62
  result = asyncio.run(watcher.process_changed_file_once(str(app_file)))
58
63
 
@@ -332,3 +337,80 @@ def test_visual_differ_initialized_when_enabled():
332
337
  assert isinstance(watcher.visual_differ, VisualDiffer)
333
338
  assert watcher.visual_differ.cfg.enabled is True
334
339
  assert watcher.visual_differ.base_url == "http://localhost:9000"
340
+
341
+
342
+ def test_get_config_endpoints_for_service_keeps_connect_pages_on_frontend():
343
+ """Frontend page routes from explicit_endpoints must not be rebound to backend/api_base_url."""
344
+ with tempfile.TemporaryDirectory() as tmpdir:
345
+ config = WupConfig(
346
+ project=ProjectConfig(name="c2004"),
347
+ watch=WatchConfig(),
348
+ services=[
349
+ ServiceConfig(name="frontend", type="web", paths=["frontend/**"]),
350
+ ServiceConfig(name="backend", type="web", paths=["backend/**"]),
351
+ ],
352
+ test_strategy=TestStrategyConfig(),
353
+ testql=TestQLConfig(
354
+ base_url="http://localhost:8100",
355
+ api_base_url="http://localhost:8101",
356
+ explicit_endpoints=["/connect-config-sitemap", "/connect-data"],
357
+ ),
358
+ visual_diff=VisualDiffConfig(enabled=True),
359
+ )
360
+
361
+ watcher = TestQLWatcher(tmpdir, config=config)
362
+
363
+ frontend_endpoints = watcher._get_config_endpoints_for_service("frontend")
364
+ backend_endpoints = watcher._get_config_endpoints_for_service("backend")
365
+
366
+ assert "http://localhost:8100/connect-config-sitemap" in frontend_endpoints
367
+ assert "http://localhost:8100/connect-data" in frontend_endpoints
368
+ assert "http://localhost:8101/connect-config-sitemap" not in backend_endpoints
369
+ assert "http://localhost:8101/connect-data" not in backend_endpoints
370
+
371
+
372
+ def test_quick_pass_actions_prefer_config_endpoints_for_visual_diff():
373
+ """Visual diff should prefer config endpoints over merged mapper endpoints."""
374
+ with tempfile.TemporaryDirectory() as tmpdir:
375
+ config = WupConfig(
376
+ project=ProjectConfig(name="c2004"),
377
+ watch=WatchConfig(),
378
+ services=[
379
+ ServiceConfig(name="frontend", type="web", paths=["frontend/**"]),
380
+ ServiceConfig(name="backend", type="web", paths=["backend/**"]),
381
+ ],
382
+ test_strategy=TestStrategyConfig(),
383
+ testql=TestQLConfig(
384
+ base_url="http://localhost:8100",
385
+ api_base_url="http://localhost:8101",
386
+ endpoints_by_service={"backend": ["http://localhost:8101/api/v3/health"]},
387
+ explicit_endpoints=["/connect-config-sitemap", "/connect-data"],
388
+ ),
389
+ visual_diff=VisualDiffConfig(enabled=True),
390
+ )
391
+
392
+ watcher = TestQLWatcher(tmpdir, config=config)
393
+
394
+ class RecordingDiffer:
395
+ def __init__(self):
396
+ self.cfg = VisualDiffConfig(enabled=True)
397
+ self.calls = []
398
+
399
+ async def run_for_service(self, service, endpoints):
400
+ self.calls.append((service, list(endpoints)))
401
+ return []
402
+
403
+ differ = RecordingDiffer()
404
+ watcher.visual_differ = differ
405
+
406
+ asyncio.run(
407
+ watcher._quick_pass_actions(
408
+ "backend",
409
+ [
410
+ "http://localhost:8101/connect-config-sitemap",
411
+ "http://localhost:8101/connect-data",
412
+ ],
413
+ )
414
+ )
415
+
416
+ assert differ.calls == [("backend", ["http://localhost:8101/api/v3/health"])]
@@ -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
@@ -21,7 +23,13 @@ from wup.models.config import (
21
23
  VisualDiffConfig,
22
24
  )
23
25
  from wup.testql_watcher import TestQLWatcher
24
- from wup.visual_diff import VisualDiffer, _diff_snapshots, _page_slug, _resolve_base_url
26
+ from wup.visual_diff import (
27
+ VisualDiffer,
28
+ _diff_snapshots,
29
+ _looks_like_visual_page,
30
+ _page_slug,
31
+ _resolve_base_url,
32
+ )
25
33
 
26
34
 
27
35
  class TestDependencyMapper:
@@ -603,6 +611,50 @@ class TestWupWatcher:
603
611
 
604
612
  # No filtering should occur
605
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
+
606
658
 
607
659
  class TestIntegrationWorkflow:
608
660
  """Integration tests for complete workflows."""
@@ -994,9 +1046,29 @@ class TestVisualDiffer:
994
1046
  pages_from_endpoints=True,
995
1047
  )
996
1048
  differ = VisualDiffer(tmpdir, cfg)
997
- pages = differ._pages_for_service("users", ["/api/users", "/api/users/1"])
998
- assert "http://localhost:8080/api/users" in pages
999
- assert "http://localhost:8080/api/users/1" in pages
1049
+ pages = differ._pages_for_service("users", ["/connect-users", "/connect-users/1"])
1050
+ assert "http://localhost:8080/connect-users" in pages
1051
+ assert "http://localhost:8080/connect-users/1" in pages
1052
+
1053
+ def test_looks_like_visual_page_skips_api_health_routes(self):
1054
+ assert _looks_like_visual_page("http://localhost:8101/api/v3/health") is False
1055
+ assert _looks_like_visual_page("http://localhost:8100/firmware/api/v1/execution/status") is False
1056
+ assert _looks_like_visual_page("http://localhost:8100/connect-config") is True
1057
+
1058
+ def test_pages_for_service_from_endpoints_skips_non_html_probes(self):
1059
+ with tempfile.TemporaryDirectory() as tmpdir:
1060
+ cfg = VisualDiffConfig(
1061
+ base_url="http://localhost:8080",
1062
+ pages_from_endpoints=True,
1063
+ )
1064
+ differ = VisualDiffer(tmpdir, cfg)
1065
+ pages = differ._pages_for_service(
1066
+ "backend",
1067
+ ["/api/v3/health", "/connect-config", "http://localhost:8080/connect-data"],
1068
+ )
1069
+ assert "http://localhost:8080/api/v3/health" not in pages
1070
+ assert "http://localhost:8080/connect-config" in pages
1071
+ assert "http://localhost:8080/connect-data" in pages
1000
1072
 
1001
1073
  def test_pages_for_service_fallback(self):
1002
1074
  with tempfile.TemporaryDirectory() as tmpdir:
@@ -1012,12 +1084,12 @@ class TestVisualDiffer:
1012
1084
  with tempfile.TemporaryDirectory() as tmpdir:
1013
1085
  cfg = VisualDiffConfig(
1014
1086
  base_url="http://localhost:8080",
1015
- pages=["https://example.com/health"],
1087
+ pages=["https://example.com/connect-dashboard"],
1016
1088
  pages_from_endpoints=False,
1017
1089
  )
1018
1090
  differ = VisualDiffer(tmpdir, cfg)
1019
1091
  pages = differ._pages_for_service("svc", [])
1020
- assert pages == ["https://example.com/health"]
1092
+ assert pages == ["https://example.com/connect-dashboard"]
1021
1093
 
1022
1094
  def test_diff_snapshots_baseline(self):
1023
1095
  new = {"tag": "HTML", "children": [{"tag": "BODY"}]}
@@ -1055,6 +1127,45 @@ class TestVisualDiffer:
1055
1127
  results = asyncio.run(differ.run_for_service("svc", ["/x"]))
1056
1128
  assert results == []
1057
1129
 
1130
+ def test_run_for_service_summarizes_fetch_errors(self, monkeypatch):
1131
+ """Fetch failures should be reported once per service, not once per page."""
1132
+ import asyncio
1133
+ from wup import visual_diff as visual_diff_module
1134
+
1135
+ with tempfile.TemporaryDirectory() as tmpdir:
1136
+ cfg = VisualDiffConfig(
1137
+ enabled=True,
1138
+ base_url="http://localhost:8100",
1139
+ pages_from_endpoints=True,
1140
+ delay_seconds=0,
1141
+ )
1142
+ differ = VisualDiffer(tmpdir, cfg)
1143
+
1144
+ async def fake_check_page(service, url):
1145
+ return {
1146
+ "url": url,
1147
+ "diff": {
1148
+ "status": "error",
1149
+ "message": "BrowserType.launch: Executable doesn't exist at /tmp/chrome",
1150
+ },
1151
+ }
1152
+
1153
+ printed = []
1154
+
1155
+ monkeypatch.setattr(visual_diff_module, "_playwright_available", lambda: True)
1156
+ monkeypatch.setattr(differ, "_check_page", fake_check_page)
1157
+ monkeypatch.setattr(visual_diff_module.console, "print", lambda message: printed.append(str(message)))
1158
+
1159
+ results = asyncio.run(
1160
+ differ.run_for_service("frontend", ["/a", "/b", "/c", "/d"])
1161
+ )
1162
+
1163
+ assert len(results) == 4
1164
+ assert len(printed) == 1
1165
+ assert "Visual diff skipped for frontend: 4 page(s) failed to fetch" in printed[0]
1166
+ assert "/a, /b, /c (+1 more)" in printed[0]
1167
+ assert "4x BrowserType.launch: Executable doesn't exist at /tmp/chrome" in printed[0]
1168
+
1058
1169
  def test_get_recent_diffs_empty(self):
1059
1170
  with tempfile.TemporaryDirectory() as tmpdir:
1060
1171
  cfg = VisualDiffConfig()
@@ -1315,8 +1426,29 @@ visual_diff:
1315
1426
  assert vd.base_url == ""
1316
1427
  assert vd.max_depth == 10
1317
1428
  assert vd.pages == []
1429
+ assert vd.pages_from_endpoints is True
1318
1430
  assert vd.headless is True
1319
1431
 
1432
+ def test_load_config_visual_diff_env_overrides_page_discovery(self, monkeypatch):
1433
+ """Env can widen visual page discovery for large frontend apps."""
1434
+ monkeypatch.setenv("WUP_VISUAL_DIFF_MAX_PAGES", "200")
1435
+ monkeypatch.setenv("WUP_VISUAL_DIFF_PAGES_FROM_ENDPOINTS", "true")
1436
+ with tempfile.TemporaryDirectory() as tmpdir:
1437
+ config_path = Path(tmpdir) / "wup.yaml"
1438
+ config_path.write_text(
1439
+ "project:\n"
1440
+ " name: x\n"
1441
+ "visual_diff:\n"
1442
+ " enabled: true\n"
1443
+ " pages_from_endpoints: false\n"
1444
+ " max_pages: 5\n",
1445
+ encoding="utf-8",
1446
+ )
1447
+ config = load_config(Path(tmpdir), config_path)
1448
+ vd = config.visual_diff
1449
+ assert vd.pages_from_endpoints is True
1450
+ assert vd.max_pages == 200
1451
+
1320
1452
  def test_load_dotenv_sets_env_var(self):
1321
1453
  """_load_dotenv should load .wup.env into os.environ."""
1322
1454
  import os
@@ -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.29"
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())