wup 0.2.29__tar.gz → 0.2.32__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.32}/PKG-INFO +9 -7
  2. {wup-0.2.29 → wup-0.2.32}/README.md +6 -6
  3. {wup-0.2.29 → wup-0.2.32}/pyproject.toml +6 -1
  4. {wup-0.2.29 → wup-0.2.32}/tests/test_testql_watcher.py +86 -4
  5. {wup-0.2.29 → wup-0.2.32}/tests/test_wup.py +92 -6
  6. {wup-0.2.29 → wup-0.2.32}/wup/__init__.py +1 -1
  7. {wup-0.2.29 → wup-0.2.32}/wup/config.py +31 -17
  8. {wup-0.2.29 → wup-0.2.32}/wup/testql_monitor.py +5 -1
  9. {wup-0.2.29 → wup-0.2.32}/wup/testql_watcher.py +24 -8
  10. {wup-0.2.29 → wup-0.2.32}/wup/visual_diff.py +103 -19
  11. {wup-0.2.29 → wup-0.2.32/wup.egg-info}/PKG-INFO +9 -7
  12. {wup-0.2.29 → wup-0.2.32}/wup.egg-info/requires.txt +3 -0
  13. {wup-0.2.29 → wup-0.2.32}/LICENSE +0 -0
  14. {wup-0.2.29 → wup-0.2.32}/setup.cfg +0 -0
  15. {wup-0.2.29 → wup-0.2.32}/tests/test_e2e.py +0 -0
  16. {wup-0.2.29 → wup-0.2.32}/tests/test_monitoring_manifest.py +0 -0
  17. {wup-0.2.29 → wup-0.2.32}/tests/test_testql_monitor.py +0 -0
  18. {wup-0.2.29 → wup-0.2.32}/tests/test_web_client.py +0 -0
  19. {wup-0.2.29 → wup-0.2.32}/wup/_ast_detector.py +0 -0
  20. {wup-0.2.29 → wup-0.2.32}/wup/_hash_detector.py +0 -0
  21. {wup-0.2.29 → wup-0.2.32}/wup/_yaml_detector.py +0 -0
  22. {wup-0.2.29 → wup-0.2.32}/wup/anomaly_detector.py +0 -0
  23. {wup-0.2.29 → wup-0.2.32}/wup/anomaly_models.py +0 -0
  24. {wup-0.2.29 → wup-0.2.32}/wup/assistant.py +0 -0
  25. {wup-0.2.29 → wup-0.2.32}/wup/cli.py +0 -0
  26. {wup-0.2.29 → wup-0.2.32}/wup/core.py +0 -0
  27. {wup-0.2.29 → wup-0.2.32}/wup/dependency_mapper.py +0 -0
  28. {wup-0.2.29 → wup-0.2.32}/wup/models/__init__.py +0 -0
  29. {wup-0.2.29 → wup-0.2.32}/wup/models/config.py +0 -0
  30. {wup-0.2.29 → wup-0.2.32}/wup/monitoring_manifest.py +0 -0
  31. {wup-0.2.29 → wup-0.2.32}/wup/testql_discovery.py +0 -0
  32. {wup-0.2.29 → wup-0.2.32}/wup/web_client.py +0 -0
  33. {wup-0.2.29 → wup-0.2.32}/wup.egg-info/SOURCES.txt +0 -0
  34. {wup-0.2.29 → wup-0.2.32}/wup.egg-info/dependency_links.txt +0 -0
  35. {wup-0.2.29 → wup-0.2.32}/wup.egg-info/entry_points.txt +0 -0
  36. {wup-0.2.29 → wup-0.2.32}/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.32
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.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
36
 
35
- - 🤖 **LLM usage:** $2.2757 (39 commits)
36
- - 👤 **Human dev:** ~$1732 (17.3h @ $100/h, 30min dedup)
37
+ - 🤖 **LLM usage:** $2.5178 (42 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.32-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.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)
8
8
 
9
- - 🤖 **LLM usage:** $2.2757 (39 commits)
10
- - 👤 **Human dev:** ~$1732 (17.3h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $2.5178 (42 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.32-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.32"
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"])]
@@ -21,7 +21,13 @@ from wup.models.config import (
21
21
  VisualDiffConfig,
22
22
  )
23
23
  from wup.testql_watcher import TestQLWatcher
24
- from wup.visual_diff import VisualDiffer, _diff_snapshots, _page_slug, _resolve_base_url
24
+ from wup.visual_diff import (
25
+ VisualDiffer,
26
+ _diff_snapshots,
27
+ _looks_like_visual_page,
28
+ _page_slug,
29
+ _resolve_base_url,
30
+ )
25
31
 
26
32
 
27
33
  class TestDependencyMapper:
@@ -994,9 +1000,29 @@ class TestVisualDiffer:
994
1000
  pages_from_endpoints=True,
995
1001
  )
996
1002
  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
1003
+ pages = differ._pages_for_service("users", ["/connect-users", "/connect-users/1"])
1004
+ assert "http://localhost:8080/connect-users" in pages
1005
+ assert "http://localhost:8080/connect-users/1" in pages
1006
+
1007
+ def test_looks_like_visual_page_skips_api_health_routes(self):
1008
+ assert _looks_like_visual_page("http://localhost:8101/api/v3/health") is False
1009
+ assert _looks_like_visual_page("http://localhost:8100/firmware/api/v1/execution/status") is False
1010
+ assert _looks_like_visual_page("http://localhost:8100/connect-config") is True
1011
+
1012
+ def test_pages_for_service_from_endpoints_skips_non_html_probes(self):
1013
+ with tempfile.TemporaryDirectory() as tmpdir:
1014
+ cfg = VisualDiffConfig(
1015
+ base_url="http://localhost:8080",
1016
+ pages_from_endpoints=True,
1017
+ )
1018
+ differ = VisualDiffer(tmpdir, cfg)
1019
+ pages = differ._pages_for_service(
1020
+ "backend",
1021
+ ["/api/v3/health", "/connect-config", "http://localhost:8080/connect-data"],
1022
+ )
1023
+ assert "http://localhost:8080/api/v3/health" not in pages
1024
+ assert "http://localhost:8080/connect-config" in pages
1025
+ assert "http://localhost:8080/connect-data" in pages
1000
1026
 
1001
1027
  def test_pages_for_service_fallback(self):
1002
1028
  with tempfile.TemporaryDirectory() as tmpdir:
@@ -1012,12 +1038,12 @@ class TestVisualDiffer:
1012
1038
  with tempfile.TemporaryDirectory() as tmpdir:
1013
1039
  cfg = VisualDiffConfig(
1014
1040
  base_url="http://localhost:8080",
1015
- pages=["https://example.com/health"],
1041
+ pages=["https://example.com/connect-dashboard"],
1016
1042
  pages_from_endpoints=False,
1017
1043
  )
1018
1044
  differ = VisualDiffer(tmpdir, cfg)
1019
1045
  pages = differ._pages_for_service("svc", [])
1020
- assert pages == ["https://example.com/health"]
1046
+ assert pages == ["https://example.com/connect-dashboard"]
1021
1047
 
1022
1048
  def test_diff_snapshots_baseline(self):
1023
1049
  new = {"tag": "HTML", "children": [{"tag": "BODY"}]}
@@ -1055,6 +1081,45 @@ class TestVisualDiffer:
1055
1081
  results = asyncio.run(differ.run_for_service("svc", ["/x"]))
1056
1082
  assert results == []
1057
1083
 
1084
+ def test_run_for_service_summarizes_fetch_errors(self, monkeypatch):
1085
+ """Fetch failures should be reported once per service, not once per page."""
1086
+ import asyncio
1087
+ from wup import visual_diff as visual_diff_module
1088
+
1089
+ with tempfile.TemporaryDirectory() as tmpdir:
1090
+ cfg = VisualDiffConfig(
1091
+ enabled=True,
1092
+ base_url="http://localhost:8100",
1093
+ pages_from_endpoints=True,
1094
+ delay_seconds=0,
1095
+ )
1096
+ differ = VisualDiffer(tmpdir, cfg)
1097
+
1098
+ async def fake_check_page(service, url):
1099
+ return {
1100
+ "url": url,
1101
+ "diff": {
1102
+ "status": "error",
1103
+ "message": "BrowserType.launch: Executable doesn't exist at /tmp/chrome",
1104
+ },
1105
+ }
1106
+
1107
+ printed = []
1108
+
1109
+ monkeypatch.setattr(visual_diff_module, "_playwright_available", lambda: True)
1110
+ monkeypatch.setattr(differ, "_check_page", fake_check_page)
1111
+ monkeypatch.setattr(visual_diff_module.console, "print", lambda message: printed.append(str(message)))
1112
+
1113
+ results = asyncio.run(
1114
+ differ.run_for_service("frontend", ["/a", "/b", "/c", "/d"])
1115
+ )
1116
+
1117
+ assert len(results) == 4
1118
+ assert len(printed) == 1
1119
+ assert "Visual diff skipped for frontend: 4 page(s) failed to fetch" in printed[0]
1120
+ assert "/a, /b, /c (+1 more)" in printed[0]
1121
+ assert "4x BrowserType.launch: Executable doesn't exist at /tmp/chrome" in printed[0]
1122
+
1058
1123
  def test_get_recent_diffs_empty(self):
1059
1124
  with tempfile.TemporaryDirectory() as tmpdir:
1060
1125
  cfg = VisualDiffConfig()
@@ -1315,8 +1380,29 @@ visual_diff:
1315
1380
  assert vd.base_url == ""
1316
1381
  assert vd.max_depth == 10
1317
1382
  assert vd.pages == []
1383
+ assert vd.pages_from_endpoints is True
1318
1384
  assert vd.headless is True
1319
1385
 
1386
+ def test_load_config_visual_diff_env_overrides_page_discovery(self, monkeypatch):
1387
+ """Env can widen visual page discovery for large frontend apps."""
1388
+ monkeypatch.setenv("WUP_VISUAL_DIFF_MAX_PAGES", "200")
1389
+ monkeypatch.setenv("WUP_VISUAL_DIFF_PAGES_FROM_ENDPOINTS", "true")
1390
+ with tempfile.TemporaryDirectory() as tmpdir:
1391
+ config_path = Path(tmpdir) / "wup.yaml"
1392
+ config_path.write_text(
1393
+ "project:\n"
1394
+ " name: x\n"
1395
+ "visual_diff:\n"
1396
+ " enabled: true\n"
1397
+ " pages_from_endpoints: false\n"
1398
+ " max_pages: 5\n",
1399
+ encoding="utf-8",
1400
+ )
1401
+ config = load_config(Path(tmpdir), config_path)
1402
+ vd = config.visual_diff
1403
+ assert vd.pages_from_endpoints is True
1404
+ assert vd.max_pages == 200
1405
+
1320
1406
  def test_load_dotenv_sets_env_var(self):
1321
1407
  """_load_dotenv should load .wup.env into os.environ."""
1322
1408
  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.32"
11
11
  __author__ = "Tom Sapletta"
12
12
 
13
13
  from .config import load_config, save_config, get_default_config
@@ -193,6 +193,8 @@ def validate_config(raw: dict) -> WupConfig:
193
193
  env_visual_enabled = os.environ.get("WUP_VISUAL_DIFF_ENABLED")
194
194
  env_visual_delay = os.environ.get("WUP_VISUAL_DIFF_DELAY_SECONDS")
195
195
  env_visual_depth = os.environ.get("WUP_VISUAL_DIFF_MAX_DEPTH")
196
+ env_visual_max_pages = os.environ.get("WUP_VISUAL_DIFF_MAX_PAGES")
197
+ env_visual_pages_from_endpoints = os.environ.get("WUP_VISUAL_DIFF_PAGES_FROM_ENDPOINTS")
196
198
 
197
199
  if env_visual_enabled is None:
198
200
  visual_enabled = vd_raw.get("enabled", False)
@@ -209,6 +211,18 @@ def validate_config(raw: dict) -> WupConfig:
209
211
  else:
210
212
  visual_depth = int(env_visual_depth)
211
213
 
214
+ if env_visual_max_pages is None:
215
+ visual_max_pages = int(vd_raw.get("max_pages", 5))
216
+ else:
217
+ visual_max_pages = int(env_visual_max_pages)
218
+
219
+ if env_visual_pages_from_endpoints is None:
220
+ visual_pages_from_endpoints = bool(vd_raw.get("pages_from_endpoints", True))
221
+ else:
222
+ visual_pages_from_endpoints = (
223
+ env_visual_pages_from_endpoints.strip().lower() in {"1", "true", "yes", "on"}
224
+ )
225
+
212
226
  visual_diff = VisualDiffConfig(
213
227
  enabled=visual_enabled,
214
228
  base_url=vd_raw.get("base_url", ""),
@@ -218,8 +232,8 @@ def validate_config(raw: dict) -> WupConfig:
218
232
  snapshot_dir=vd_raw.get("snapshot_dir", ".wup/visual-snapshots"),
219
233
  diff_dir=vd_raw.get("diff_dir", ".wup/visual-diffs"),
220
234
  pages=vd_raw.get("pages", []),
221
- pages_from_endpoints=vd_raw.get("pages_from_endpoints", False),
222
- max_pages=int(vd_raw.get("max_pages", 5)),
235
+ pages_from_endpoints=visual_pages_from_endpoints,
236
+ max_pages=visual_max_pages,
223
237
  threshold_added=int(vd_raw.get("threshold_added", 3)),
224
238
  threshold_removed=int(vd_raw.get("threshold_removed", 3)),
225
239
  threshold_changed=int(vd_raw.get("threshold_changed", 5)),
@@ -294,24 +308,24 @@ def save_config(config: WupConfig, output_path: Path):
294
308
 
295
309
  # Build metadata header
296
310
  header_lines = [
297
- f"# WUP (What's Up) Configuration",
311
+ "# WUP (What's Up) Configuration",
298
312
  f"# Version: {__version__}",
299
313
  f"# Generated: {__import__('datetime').datetime.now().isoformat()}",
300
- f"#",
301
- f"# Documentation:",
302
- f"# PyPI: https://pypi.org/project/wup/",
303
- f"# GitHub: https://github.com/semcod/wup",
304
- f"# Docs: https://github.com/semcod/wup/blob/main/README.md",
305
- f"#",
306
- f"# Dependencies:",
314
+ "#",
315
+ "# Documentation:",
316
+ "# PyPI: https://pypi.org/project/wup/",
317
+ "# GitHub: https://github.com/semcod/wup",
318
+ "# Docs: https://github.com/semcod/wup/blob/main/README.md",
319
+ "#",
320
+ "# Dependencies:",
307
321
  f"# wup=={__version__}",
308
- f"# wupbro (optional dashboard): pip install wupbro",
309
- f"#",
310
- f"# Quick Start:",
311
- f"# 1. wup watch . # TestQL + live probes every 60s",
312
- f"# 2. wup watch . --dashboard # With live dashboard",
313
- f"# 3. wup map-deps . # Build dependency map",
314
- f"#",
322
+ "# wupbro (optional dashboard): pip install wupbro",
323
+ "#",
324
+ "# Quick Start:",
325
+ "# 1. wup watch . # TestQL + live probes every 60s",
326
+ "# 2. wup watch . --dashboard # With live dashboard",
327
+ "# 3. wup map-deps . # Build dependency map",
328
+ "#",
315
329
  ""
316
330
  ]
317
331
 
@@ -11,7 +11,7 @@ from urllib.parse import urlparse
11
11
 
12
12
  import yaml
13
13
 
14
- from .models.config import ServiceConfig, TestQLConfig, WupConfig
14
+ from .models.config import ServiceConfig, WupConfig
15
15
  from .testql_discovery import TestQLEndpointDiscovery
16
16
 
17
17
  _API_LINE = re.compile(
@@ -209,6 +209,10 @@ def assign_probe_to_service(probe: ProbeTarget, services: Sequence[ServiceConfig
209
209
  if best:
210
210
  return best
211
211
 
212
+ if path_lower.startswith("/connect-"):
213
+ for svc in services:
214
+ if svc.name.lower() == "frontend":
215
+ return svc.name
212
216
  if path_lower.startswith("/firmware"):
213
217
  for svc in services:
214
218
  if "firmware" in svc.name.lower():
@@ -222,13 +222,12 @@ class TestQLWatcher(WupWatcher):
222
222
  merged.append(endpoint_url)
223
223
 
224
224
  for endpoint in explicit:
225
- probe = ProbeTarget(
226
- url=self._to_full_url_for_service(service, endpoint) or endpoint,
227
- source="wup.yaml:explicit_endpoints",
228
- )
229
- if assign_probe_to_service(probe, self.config.services) == service:
230
- if probe.url not in merged:
231
- merged.append(probe.url)
225
+ raw_probe = ProbeTarget(url=endpoint, source="wup.yaml:explicit_endpoints")
226
+ if assign_probe_to_service(raw_probe, self.config.services) != service:
227
+ continue
228
+ endpoint_url = self._to_full_url_for_service(service, endpoint)
229
+ if endpoint_url and endpoint_url not in merged:
230
+ merged.append(endpoint_url)
232
231
  return merged
233
232
 
234
233
  def _to_full_url_for_service(self, service: str, endpoint: str) -> str:
@@ -442,7 +441,24 @@ class TestQLWatcher(WupWatcher):
442
441
  await self.web_client.send_pass(service=service, stage="quick")
443
442
  self.console.print(f"[green]✓ Quick TestQL passed for {service}[/green]")
444
443
  if self.visual_differ and self.visual_differ.cfg.enabled:
445
- visual_results = await self.visual_differ.run_for_service(service, merged_endpoints)
444
+ visual_endpoints = self._get_config_endpoints_for_service(service) or merged_endpoints
445
+ visual_results = await self.visual_differ.run_for_service(service, visual_endpoints)
446
+ visual_issues = [
447
+ item for item in visual_results
448
+ if item.get("diff", {}).get("status") == "issue"
449
+ ]
450
+ if visual_issues:
451
+ issue_text = "; ".join(
452
+ ", ".join(item.get("diff", {}).get("issues", []) or ["visual page issue"])
453
+ for item in visual_issues
454
+ )
455
+ self._record_health_transition(
456
+ service=service,
457
+ status="down",
458
+ stage="visual",
459
+ message=issue_text or "visual page issue",
460
+ track_file="",
461
+ )
446
462
  await self._publish_visual_events(service, visual_results)
447
463
 
448
464
  def _quick_probe_limit(self, service: str) -> int:
@@ -15,13 +15,13 @@ gracefully and logs a warning.
15
15
  from __future__ import annotations
16
16
 
17
17
  import asyncio
18
- import hashlib
19
18
  import json
20
19
  import os
21
20
  import time
21
+ from collections import Counter
22
22
  from pathlib import Path
23
23
  from typing import Any, Dict, List, Optional, Tuple
24
- from urllib.parse import urljoin, urlparse
24
+ from urllib.parse import urlparse
25
25
 
26
26
  from rich.console import Console
27
27
 
@@ -94,11 +94,11 @@ async def _fetch_dom_snapshot(
94
94
  max_depth: int,
95
95
  headless: bool,
96
96
  error_selectors: List[str],
97
- ) -> Optional[Dict]:
97
+ ) -> Tuple[Optional[Dict], Optional[str]]:
98
98
  """Return a DOM structure dict for *url* using Playwright."""
99
99
  if not _playwright_available():
100
100
  _warn_playwright_missing()
101
- return None
101
+ return None, "Playwright not installed"
102
102
  try:
103
103
  from playwright.async_api import async_playwright
104
104
  async with async_playwright() as pw:
@@ -126,10 +126,9 @@ async def _fetch_dom_snapshot(
126
126
  }
127
127
  finally:
128
128
  await browser.close()
129
- return snapshot
129
+ return snapshot, None
130
130
  except Exception as exc:
131
- console.print(f"[yellow]visual_diff: error fetching {url}: {exc}[/yellow]")
132
- return None
131
+ return None, str(exc)
133
132
 
134
133
 
135
134
  def _detect_content_issues(snapshot: Dict, cfg: VisualDiffConfig) -> List[str]:
@@ -158,6 +157,53 @@ def _page_slug(url: str) -> str:
158
157
  return path[:80]
159
158
 
160
159
 
160
+ def _short_url(url: str) -> str:
161
+ parsed = urlparse(url)
162
+ path = parsed.path or "/"
163
+ return f"{path}?{parsed.query}" if parsed.query else path
164
+
165
+
166
+ def _compact_error_message(message: str, max_len: int = 180) -> str:
167
+ compact = " ".join((message or "").split())
168
+ if len(compact) <= max_len:
169
+ return compact
170
+ return compact[: max_len - 3] + "..."
171
+
172
+
173
+ def _sample_list(items: List[str], limit: int = 3) -> str:
174
+ if not items:
175
+ return ""
176
+ sample = ", ".join(items[:limit])
177
+ remaining = len(items) - limit
178
+ if remaining > 0:
179
+ sample += f" (+{remaining} more)"
180
+ return sample
181
+
182
+
183
+ def _looks_like_visual_page(url: str) -> bool:
184
+ parsed = urlparse(url)
185
+ path = (parsed.path or "").lower()
186
+ if not path or path in {"/", "/index.html"}:
187
+ return True
188
+ if path.startswith("/api/"):
189
+ return False
190
+ if any(
191
+ token in path
192
+ for token in (
193
+ "/health",
194
+ "/healthz",
195
+ "/ready",
196
+ "/live",
197
+ "/status",
198
+ "/openapi",
199
+ "/execution/logs",
200
+ "/execution/status",
201
+ )
202
+ ):
203
+ return False
204
+ return True
205
+
206
+
161
207
  def _snapshot_path(snapshot_dir: Path, service: str, url: str) -> Path:
162
208
  slug = _page_slug(url)
163
209
  svc_safe = service.replace("/", "_")
@@ -295,9 +341,12 @@ class VisualDiffer:
295
341
  result = []
296
342
  for p in pages:
297
343
  if p.startswith("http://") or p.startswith("https://"):
298
- result.append(p)
344
+ if _looks_like_visual_page(p):
345
+ result.append(p)
299
346
  else:
300
- result.append(f"{base}{p}")
347
+ url = f"{base}{p}"
348
+ if _looks_like_visual_page(url):
349
+ result.append(url)
301
350
  return result
302
351
 
303
352
  async def run_for_service(
@@ -322,12 +371,16 @@ class VisualDiffer:
322
371
  if len(pages) > max_pages:
323
372
  pages = pages[:max_pages]
324
373
  results = []
374
+ ok_urls: List[str] = []
375
+ new_urls: List[str] = []
376
+ error_results: List[Tuple[str, str]] = []
325
377
  for url in pages:
326
378
  result = await self._check_page(service, url)
327
379
  results.append(result)
328
- if result["diff"]["status"] in {"changed", "issue"}:
380
+ status = result["diff"]["status"]
381
+ if status in {"changed", "issue"}:
329
382
  self._write_diff_event(service, url, result)
330
- if result["diff"]["status"] == "issue":
383
+ if status == "issue":
331
384
  console.print(
332
385
  f"[bold red]🚨 Page issue: {service} {url}[/bold red] "
333
386
  f"{'; '.join(result['diff'].get('issues', []))}"
@@ -339,13 +392,38 @@ class VisualDiffer:
339
392
  f"-{result['diff']['counts']['removed']} "
340
393
  f"~{result['diff']['counts']['changed_attrs']}"
341
394
  )
342
- elif result["diff"]["status"] == "new":
343
- console.print(f"[dim]📷 Baseline snapshot: {url}[/dim]")
344
- elif result["diff"]["status"] == "error":
395
+ elif status == "new":
396
+ new_urls.append(_short_url(url))
397
+ elif status == "error":
345
398
  message = result["diff"].get("message", "scan failed")
346
- console.print(f"[yellow]⚠ Visual diff skipped: {url} ({message})[/yellow]")
347
- elif result["diff"]["status"] == "ok":
348
- console.print(f"[dim green]✓ No DOM change: {url}[/dim green]")
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
+ )
349
427
 
350
428
  return results
351
429
 
@@ -353,7 +431,7 @@ class VisualDiffer:
353
431
  snap_path = _snapshot_path(self.snapshot_dir, service, url)
354
432
  old_snapshot = _load_snapshot(snap_path)
355
433
 
356
- new_snapshot = await _fetch_dom_snapshot(
434
+ new_snapshot, fetch_error = await _fetch_dom_snapshot(
357
435
  url,
358
436
  self.cfg.max_depth,
359
437
  self.cfg.headless,
@@ -361,7 +439,13 @@ class VisualDiffer:
361
439
  )
362
440
 
363
441
  if new_snapshot is None:
364
- return {"url": url, "diff": {"status": "error", "message": "Failed to fetch page"}}
442
+ return {
443
+ "url": url,
444
+ "diff": {
445
+ "status": "error",
446
+ "message": _compact_error_message(fetch_error or "Failed to fetch page"),
447
+ },
448
+ }
365
449
 
366
450
  diff = _diff_snapshots(
367
451
  old_snapshot,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wup
3
- Version: 0.2.29
3
+ Version: 0.2.32
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.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
36
 
35
- - 🤖 **LLM usage:** $2.2757 (39 commits)
36
- - 👤 **Human dev:** ~$1732 (17.3h @ $100/h, 30min dedup)
37
+ - 🤖 **LLM usage:** $2.5178 (42 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.32-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,3 +3,6 @@ psutil>=5.9.0
3
3
  rich>=13.0.0
4
4
  typer>=0.9.0
5
5
  pyyaml>=6.0
6
+
7
+ [visual]
8
+ playwright<2,>=1.40
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