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.
- {wup-0.2.29/wup.egg-info → wup-0.2.32}/PKG-INFO +9 -7
- {wup-0.2.29 → wup-0.2.32}/README.md +6 -6
- {wup-0.2.29 → wup-0.2.32}/pyproject.toml +6 -1
- {wup-0.2.29 → wup-0.2.32}/tests/test_testql_watcher.py +86 -4
- {wup-0.2.29 → wup-0.2.32}/tests/test_wup.py +92 -6
- {wup-0.2.29 → wup-0.2.32}/wup/__init__.py +1 -1
- {wup-0.2.29 → wup-0.2.32}/wup/config.py +31 -17
- {wup-0.2.29 → wup-0.2.32}/wup/testql_monitor.py +5 -1
- {wup-0.2.29 → wup-0.2.32}/wup/testql_watcher.py +24 -8
- {wup-0.2.29 → wup-0.2.32}/wup/visual_diff.py +103 -19
- {wup-0.2.29 → wup-0.2.32/wup.egg-info}/PKG-INFO +9 -7
- {wup-0.2.29 → wup-0.2.32}/wup.egg-info/requires.txt +3 -0
- {wup-0.2.29 → wup-0.2.32}/LICENSE +0 -0
- {wup-0.2.29 → wup-0.2.32}/setup.cfg +0 -0
- {wup-0.2.29 → wup-0.2.32}/tests/test_e2e.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/tests/test_monitoring_manifest.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/tests/test_testql_monitor.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/tests/test_web_client.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup/_ast_detector.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup/_hash_detector.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup/_yaml_detector.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup/anomaly_detector.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup/anomaly_models.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup/assistant.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup/cli.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup/core.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup/dependency_mapper.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup/models/__init__.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup/models/config.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup/monitoring_manifest.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup/testql_discovery.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup/web_client.py +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup.egg-info/SOURCES.txt +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.29 → wup-0.2.32}/wup.egg-info/entry_points.txt +0 -0
- {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.
|
|
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
|
-
    
|
|
35
|
+
  
|
|
34
36
|
|
|
35
|
-
- 🤖 **LLM usage:** $2.
|
|
36
|
-
- 👤 **Human dev:** ~$
|
|
37
|
+
- 🤖 **LLM usage:** $2.5178 (42 commits)
|
|
38
|
+
- 👤 **Human dev:** ~$1832 (18.3h @ $100/h, 30min dedup)
|
|
37
39
|
|
|
38
|
-
Generated on 2026-05-
|
|
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
|
-
    
|
|
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
|
-
    
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $2.
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $2.5178 (42 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$1832 (18.3h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
|
-
Generated on 2026-05-
|
|
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
|
-
    
|
|
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.
|
|
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 / "
|
|
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
|
|
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
|
|
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", ["/
|
|
998
|
-
assert "http://localhost:8080/
|
|
999
|
-
assert "http://localhost:8080/
|
|
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/
|
|
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/
|
|
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.
|
|
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=
|
|
222
|
-
max_pages=
|
|
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
|
-
|
|
311
|
+
"# WUP (What's Up) Configuration",
|
|
298
312
|
f"# Version: {__version__}",
|
|
299
313
|
f"# Generated: {__import__('datetime').datetime.now().isoformat()}",
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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,
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
)
|
|
229
|
-
if
|
|
230
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
344
|
+
if _looks_like_visual_page(p):
|
|
345
|
+
result.append(p)
|
|
299
346
|
else:
|
|
300
|
-
|
|
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
|
-
|
|
380
|
+
status = result["diff"]["status"]
|
|
381
|
+
if status in {"changed", "issue"}:
|
|
329
382
|
self._write_diff_event(service, url, result)
|
|
330
|
-
if
|
|
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
|
|
343
|
-
|
|
344
|
-
elif
|
|
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
|
-
|
|
347
|
-
elif
|
|
348
|
-
|
|
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 {
|
|
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.
|
|
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
|
-
    
|
|
35
|
+
  
|
|
34
36
|
|
|
35
|
-
- 🤖 **LLM usage:** $2.
|
|
36
|
-
- 👤 **Human dev:** ~$
|
|
37
|
+
- 🤖 **LLM usage:** $2.5178 (42 commits)
|
|
38
|
+
- 👤 **Human dev:** ~$1832 (18.3h @ $100/h, 30min dedup)
|
|
37
39
|
|
|
38
|
-
Generated on 2026-05-
|
|
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
|
-
    
|
|
43
45
|
|
|
44
46
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
45
47
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|