wup 0.2.12__tar.gz → 0.2.14__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.12/wup.egg-info → wup-0.2.14}/PKG-INFO +6 -6
- {wup-0.2.12 → wup-0.2.14}/README.md +5 -5
- {wup-0.2.12 → wup-0.2.14}/pyproject.toml +1 -1
- {wup-0.2.12 → wup-0.2.14}/wup/__init__.py +1 -1
- {wup-0.2.12 → wup-0.2.14}/wup/cli.py +22 -0
- {wup-0.2.12 → wup-0.2.14}/wup/config.py +60 -2
- {wup-0.2.12 → wup-0.2.14}/wup/models/config.py +19 -0
- {wup-0.2.12 → wup-0.2.14}/wup/testql_watcher.py +6 -0
- wup-0.2.14/wup/visual_diff.py +333 -0
- {wup-0.2.12 → wup-0.2.14/wup.egg-info}/PKG-INFO +6 -6
- {wup-0.2.12 → wup-0.2.14}/wup.egg-info/SOURCES.txt +1 -0
- {wup-0.2.12 → wup-0.2.14}/LICENSE +0 -0
- {wup-0.2.12 → wup-0.2.14}/setup.cfg +0 -0
- {wup-0.2.12 → wup-0.2.14}/tests/test_e2e.py +0 -0
- {wup-0.2.12 → wup-0.2.14}/tests/test_testql_watcher.py +0 -0
- {wup-0.2.12 → wup-0.2.14}/tests/test_wup.py +0 -0
- {wup-0.2.12 → wup-0.2.14}/wup/core.py +0 -0
- {wup-0.2.12 → wup-0.2.14}/wup/dependency_mapper.py +0 -0
- {wup-0.2.12 → wup-0.2.14}/wup/models/__init__.py +0 -0
- {wup-0.2.12 → wup-0.2.14}/wup/testql_discovery.py +0 -0
- {wup-0.2.12 → wup-0.2.14}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.12 → wup-0.2.14}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.12 → wup-0.2.14}/wup.egg-info/requires.txt +0 -0
- {wup-0.2.12 → wup-0.2.14}/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.14
|
|
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: Apache-2.0
|
|
@@ -29,17 +29,17 @@ Dynamic: license-file
|
|
|
29
29
|
|
|
30
30
|
## AI Cost Tracking
|
|
31
31
|
|
|
32
|
-
    
|
|
33
|
+
  
|
|
34
34
|
|
|
35
|
-
- 🤖 **LLM usage:** $
|
|
36
|
-
- 👤 **Human dev:** ~$
|
|
35
|
+
- 🤖 **LLM usage:** $2.2500 (15 commits)
|
|
36
|
+
- 👤 **Human dev:** ~$324 (3.2h @ $100/h, 30min dedup)
|
|
37
37
|
|
|
38
38
|
Generated on 2026-04-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
-
    
|
|
43
43
|
|
|
44
44
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
45
45
|
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
    
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $2.2500 (15 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$324 (3.2h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
12
|
Generated on 2026-04-29 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
|
|
|
@@ -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.14"
|
|
11
11
|
__author__ = "Tom Sapletta"
|
|
12
12
|
|
|
13
13
|
from .config import load_config, save_config, get_default_config
|
|
@@ -274,6 +274,28 @@ def status(
|
|
|
274
274
|
if track_file:
|
|
275
275
|
lines.append(Text.from_markup(f" [dim]track: {track_file}[/dim]"))
|
|
276
276
|
|
|
277
|
+
# --- visual diff section ---
|
|
278
|
+
if wup_config.visual_diff and wup_config.visual_diff.enabled:
|
|
279
|
+
from .visual_diff import VisualDiffer
|
|
280
|
+
differ = VisualDiffer(str(project_path), wup_config.visual_diff)
|
|
281
|
+
vd_seconds = effective_delta if effective_delta > 0 else 300
|
|
282
|
+
recent_vd = differ.get_recent_diffs(vd_seconds)
|
|
283
|
+
lines.append(Text(""))
|
|
284
|
+
lines.append(Text.from_markup(f"[bold]Visual DOM diffs (last {vd_seconds}s):[/bold]"))
|
|
285
|
+
if not recent_vd:
|
|
286
|
+
lines.append(Text.from_markup(" [dim]No DOM changes detected[/dim]"))
|
|
287
|
+
else:
|
|
288
|
+
for entry in recent_vd[:10]:
|
|
289
|
+
url = entry.get("url", "?")
|
|
290
|
+
diff = entry.get("diff", {})
|
|
291
|
+
counts = diff.get("counts", {})
|
|
292
|
+
status = diff.get("status", "?")
|
|
293
|
+
color = "yellow" if status == "changed" else "dim green"
|
|
294
|
+
lines.append(Text.from_markup(
|
|
295
|
+
f" [{color}]{url}[/{color}] "
|
|
296
|
+
f"+{counts.get('added', 0)} -{counts.get('removed', 0)} ~{counts.get('changed_attrs', 0)}"
|
|
297
|
+
))
|
|
298
|
+
|
|
277
299
|
return Group(*lines)
|
|
278
300
|
|
|
279
301
|
if not watch:
|
|
@@ -4,6 +4,7 @@ Configuration loader for WUP.
|
|
|
4
4
|
Handles loading and validation of wup.yaml configuration files.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import os
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Optional
|
|
9
10
|
|
|
@@ -18,6 +19,7 @@ from .models.config import (
|
|
|
18
19
|
ProjectConfig,
|
|
19
20
|
NotifyConfig,
|
|
20
21
|
ServiceTestConfig,
|
|
22
|
+
VisualDiffConfig,
|
|
21
23
|
)
|
|
22
24
|
|
|
23
25
|
|
|
@@ -41,6 +43,26 @@ def find_config_file(project_root: Path) -> Optional[Path]:
|
|
|
41
43
|
return None
|
|
42
44
|
|
|
43
45
|
|
|
46
|
+
def _load_dotenv(project_root: Path) -> None:
|
|
47
|
+
"""Load .env and .wup.env files into os.environ (existing vars are NOT overwritten)."""
|
|
48
|
+
for env_file in (".wup.env", ".env"):
|
|
49
|
+
env_path = project_root / env_file
|
|
50
|
+
if not env_path.exists():
|
|
51
|
+
continue
|
|
52
|
+
try:
|
|
53
|
+
for line in env_path.read_text(encoding="utf-8").splitlines():
|
|
54
|
+
line = line.strip()
|
|
55
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
56
|
+
continue
|
|
57
|
+
key, _, value = line.partition("=")
|
|
58
|
+
key = key.strip()
|
|
59
|
+
value = value.strip().strip('"').strip("'")
|
|
60
|
+
if key and key not in os.environ:
|
|
61
|
+
os.environ[key] = value
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
44
66
|
def load_config(project_root: Path, config_path: Optional[Path] = None) -> WupConfig:
|
|
45
67
|
"""
|
|
46
68
|
Load and validate wup.yaml configuration.
|
|
@@ -56,6 +78,8 @@ def load_config(project_root: Path, config_path: Optional[Path] = None) -> WupCo
|
|
|
56
78
|
FileNotFoundError: If config file not found
|
|
57
79
|
ValueError: If config is invalid
|
|
58
80
|
"""
|
|
81
|
+
_load_dotenv(project_root)
|
|
82
|
+
|
|
59
83
|
if config_path is None:
|
|
60
84
|
config_path = find_config_file(project_root)
|
|
61
85
|
|
|
@@ -156,13 +180,32 @@ def validate_config(raw: dict) -> WupConfig:
|
|
|
156
180
|
explicit_endpoints=testql_raw.get("explicit_endpoints", []),
|
|
157
181
|
endpoints_by_service=testql_raw.get("endpoints_by_service", {})
|
|
158
182
|
)
|
|
159
|
-
|
|
183
|
+
|
|
184
|
+
# Parse visual_diff config
|
|
185
|
+
vd_raw = raw.get("visual_diff", {})
|
|
186
|
+
visual_diff = VisualDiffConfig(
|
|
187
|
+
enabled=vd_raw.get("enabled", False),
|
|
188
|
+
base_url=vd_raw.get("base_url", ""),
|
|
189
|
+
base_url_env=vd_raw.get("base_url_env", "WUP_BASE_URL"),
|
|
190
|
+
delay_seconds=float(vd_raw.get("delay_seconds", 5.0)),
|
|
191
|
+
max_depth=int(vd_raw.get("max_depth", 10)),
|
|
192
|
+
snapshot_dir=vd_raw.get("snapshot_dir", ".wup/visual-snapshots"),
|
|
193
|
+
diff_dir=vd_raw.get("diff_dir", ".wup/visual-diffs"),
|
|
194
|
+
pages=vd_raw.get("pages", []),
|
|
195
|
+
pages_from_endpoints=vd_raw.get("pages_from_endpoints", True),
|
|
196
|
+
threshold_added=int(vd_raw.get("threshold_added", 3)),
|
|
197
|
+
threshold_removed=int(vd_raw.get("threshold_removed", 3)),
|
|
198
|
+
threshold_changed=int(vd_raw.get("threshold_changed", 5)),
|
|
199
|
+
headless=vd_raw.get("headless", True),
|
|
200
|
+
)
|
|
201
|
+
|
|
160
202
|
return WupConfig(
|
|
161
203
|
project=project,
|
|
162
204
|
watch=watch,
|
|
163
205
|
services=services,
|
|
164
206
|
test_strategy=test_strategy,
|
|
165
|
-
testql=testql
|
|
207
|
+
testql=testql,
|
|
208
|
+
visual_diff=visual_diff,
|
|
166
209
|
)
|
|
167
210
|
|
|
168
211
|
|
|
@@ -225,6 +268,21 @@ def save_config(config: WupConfig, output_path: Path):
|
|
|
225
268
|
"base_url_env": config.testql.base_url_env,
|
|
226
269
|
"explicit_endpoints": config.testql.explicit_endpoints,
|
|
227
270
|
"endpoints_by_service": config.testql.endpoints_by_service,
|
|
271
|
+
},
|
|
272
|
+
"visual_diff": {
|
|
273
|
+
"enabled": config.visual_diff.enabled,
|
|
274
|
+
"base_url": config.visual_diff.base_url,
|
|
275
|
+
"base_url_env": config.visual_diff.base_url_env,
|
|
276
|
+
"delay_seconds": config.visual_diff.delay_seconds,
|
|
277
|
+
"max_depth": config.visual_diff.max_depth,
|
|
278
|
+
"snapshot_dir": config.visual_diff.snapshot_dir,
|
|
279
|
+
"diff_dir": config.visual_diff.diff_dir,
|
|
280
|
+
"pages": config.visual_diff.pages,
|
|
281
|
+
"pages_from_endpoints": config.visual_diff.pages_from_endpoints,
|
|
282
|
+
"threshold_added": config.visual_diff.threshold_added,
|
|
283
|
+
"threshold_removed": config.visual_diff.threshold_removed,
|
|
284
|
+
"threshold_changed": config.visual_diff.threshold_changed,
|
|
285
|
+
"headless": config.visual_diff.headless,
|
|
228
286
|
}
|
|
229
287
|
}
|
|
230
288
|
|
|
@@ -65,6 +65,24 @@ class TestQLConfig:
|
|
|
65
65
|
endpoints_by_service: Dict[str, List[str]] = field(default_factory=dict)
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
@dataclass
|
|
69
|
+
class VisualDiffConfig:
|
|
70
|
+
"""Configuration for visual DOM diff after file changes."""
|
|
71
|
+
enabled: bool = False
|
|
72
|
+
base_url: str = ""
|
|
73
|
+
base_url_env: str = "WUP_BASE_URL"
|
|
74
|
+
delay_seconds: float = 5.0 # wait after file change before scanning
|
|
75
|
+
max_depth: int = 10 # DOM depth for snapshot
|
|
76
|
+
snapshot_dir: str = ".wup/visual-snapshots"
|
|
77
|
+
diff_dir: str = ".wup/visual-diffs"
|
|
78
|
+
pages: List[str] = field(default_factory=list) # explicit page paths to scan
|
|
79
|
+
pages_from_endpoints: bool = True # infer pages from explicit_endpoints
|
|
80
|
+
threshold_added: int = 3 # min added nodes to report
|
|
81
|
+
threshold_removed: int = 3 # min removed nodes to report
|
|
82
|
+
threshold_changed: int = 5 # min changed attrs to report
|
|
83
|
+
headless: bool = True
|
|
84
|
+
|
|
85
|
+
|
|
68
86
|
@dataclass
|
|
69
87
|
class ProjectConfig:
|
|
70
88
|
"""Project metadata."""
|
|
@@ -80,3 +98,4 @@ class WupConfig:
|
|
|
80
98
|
services: List[ServiceConfig] = field(default_factory=list)
|
|
81
99
|
test_strategy: TestStrategyConfig = field(default_factory=TestStrategyConfig)
|
|
82
100
|
testql: TestQLConfig = field(default_factory=TestQLConfig)
|
|
101
|
+
visual_diff: VisualDiffConfig = field(default_factory=VisualDiffConfig)
|
|
@@ -15,6 +15,7 @@ from urllib import error, request
|
|
|
15
15
|
from .config import load_config
|
|
16
16
|
from .core import WupWatcher
|
|
17
17
|
from .models.config import WupConfig, ServiceConfig
|
|
18
|
+
from .visual_diff import VisualDiffer
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
class BrowserNotifier:
|
|
@@ -94,6 +95,7 @@ class TestQLWatcher(WupWatcher):
|
|
|
94
95
|
self.health_state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
96
|
self.service_health = self._load_service_health()
|
|
96
97
|
self.config = config
|
|
98
|
+
self.visual_differ = VisualDiffer(project_root, config.visual_diff) if config and config.visual_diff else None
|
|
97
99
|
|
|
98
100
|
def _load_service_health(self) -> Dict[str, Dict]:
|
|
99
101
|
if not self.health_state_path.exists():
|
|
@@ -379,6 +381,10 @@ class TestQLWatcher(WupWatcher):
|
|
|
379
381
|
message="Quick TestQL passed",
|
|
380
382
|
)
|
|
381
383
|
self.console.print(f"[green]✓ Quick TestQL passed for {service}[/green]")
|
|
384
|
+
if self.visual_differ and self.visual_differ.cfg.enabled:
|
|
385
|
+
asyncio.ensure_future(
|
|
386
|
+
self.visual_differ.run_for_service(service, merged_endpoints)
|
|
387
|
+
)
|
|
382
388
|
return True
|
|
383
389
|
|
|
384
390
|
async def run_detail_test(self, service: str, endpoints: List[str]) -> Dict:
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Visual DOM diff for WUP.
|
|
3
|
+
|
|
4
|
+
After a file change is detected, waits `delay_seconds`, then fetches the
|
|
5
|
+
configured pages with Playwright, records a DOM structure snapshot up to
|
|
6
|
+
`max_depth` levels, and compares with the previous snapshot.
|
|
7
|
+
|
|
8
|
+
Snapshots are stored in `.wup/visual-snapshots/<service>/<page_slug>.json`.
|
|
9
|
+
Diffs are appended to `.wup/visual-diffs/<service>/<page_slug>.jsonl`.
|
|
10
|
+
|
|
11
|
+
Playwright is an optional dependency — if not installed the module degrades
|
|
12
|
+
gracefully and logs a warning.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
24
|
+
from urllib.parse import urljoin, urlparse
|
|
25
|
+
|
|
26
|
+
from rich.console import Console
|
|
27
|
+
|
|
28
|
+
from .models.config import VisualDiffConfig
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# DOM snapshotting (Playwright)
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
_PW_AVAILABLE: Optional[bool] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _playwright_available() -> bool:
|
|
41
|
+
global _PW_AVAILABLE
|
|
42
|
+
if _PW_AVAILABLE is None:
|
|
43
|
+
try:
|
|
44
|
+
import playwright # noqa: F401
|
|
45
|
+
_PW_AVAILABLE = True
|
|
46
|
+
except ImportError:
|
|
47
|
+
_PW_AVAILABLE = False
|
|
48
|
+
return _PW_AVAILABLE
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_DOM_SNAPSHOT_JS = """
|
|
52
|
+
(maxDepth) => {
|
|
53
|
+
function snapshot(node, depth) {
|
|
54
|
+
if (!node || depth > maxDepth) return null;
|
|
55
|
+
const info = {tag: node.tagName || '#text'};
|
|
56
|
+
if (node.id) info.id = node.id;
|
|
57
|
+
if (node.className && typeof node.className === 'string' && node.className.trim())
|
|
58
|
+
info.cls = node.className.trim().split(/\\s+/).sort().join(' ');
|
|
59
|
+
const attrs = {};
|
|
60
|
+
if (node.attributes) {
|
|
61
|
+
for (const a of node.attributes) {
|
|
62
|
+
if (!['class','id','style'].includes(a.name))
|
|
63
|
+
attrs[a.name] = a.value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (Object.keys(attrs).length) info.attrs = attrs;
|
|
67
|
+
const kids = [];
|
|
68
|
+
for (const child of (node.children || [])) {
|
|
69
|
+
const s = snapshot(child, depth + 1);
|
|
70
|
+
if (s) kids.push(s);
|
|
71
|
+
}
|
|
72
|
+
if (kids.length) info.children = kids;
|
|
73
|
+
return info;
|
|
74
|
+
}
|
|
75
|
+
return snapshot(document.documentElement, 0);
|
|
76
|
+
}
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _fetch_dom_snapshot(url: str, max_depth: int, headless: bool) -> Optional[Dict]:
|
|
81
|
+
"""Return a DOM structure dict for *url* using Playwright."""
|
|
82
|
+
if not _playwright_available():
|
|
83
|
+
console.print("[yellow]visual_diff: playwright not installed — skipping DOM scan[/yellow]")
|
|
84
|
+
return None
|
|
85
|
+
try:
|
|
86
|
+
from playwright.async_api import async_playwright
|
|
87
|
+
async with async_playwright() as pw:
|
|
88
|
+
browser = await pw.chromium.launch(headless=headless)
|
|
89
|
+
page = await browser.new_page()
|
|
90
|
+
try:
|
|
91
|
+
await page.goto(url, wait_until="networkidle", timeout=15_000)
|
|
92
|
+
snapshot = await page.evaluate(_DOM_SNAPSHOT_JS, max_depth)
|
|
93
|
+
finally:
|
|
94
|
+
await browser.close()
|
|
95
|
+
return snapshot
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
console.print(f"[yellow]visual_diff: error fetching {url}: {exc}[/yellow]")
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Snapshot persistence
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def _page_slug(url: str) -> str:
|
|
106
|
+
path = urlparse(url).path.strip("/").replace("/", "_") or "root"
|
|
107
|
+
return path[:80]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _snapshot_path(snapshot_dir: Path, service: str, url: str) -> Path:
|
|
111
|
+
slug = _page_slug(url)
|
|
112
|
+
svc_safe = service.replace("/", "_")
|
|
113
|
+
return snapshot_dir / svc_safe / f"{slug}.json"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _load_snapshot(path: Path) -> Optional[Dict]:
|
|
117
|
+
if path.exists():
|
|
118
|
+
try:
|
|
119
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
120
|
+
except Exception:
|
|
121
|
+
return None
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _save_snapshot(path: Path, snapshot: Dict) -> None:
|
|
126
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
path.write_text(json.dumps(snapshot, ensure_ascii=False), encoding="utf-8")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Diff algorithm (structural)
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def _node_signature(node: Dict, depth: int = 0) -> str:
|
|
135
|
+
"""Produce a stable string identifier for a DOM node."""
|
|
136
|
+
parts = [node.get("tag", "?")]
|
|
137
|
+
if "id" in node:
|
|
138
|
+
parts.append(f"#{node['id']}")
|
|
139
|
+
if "cls" in node:
|
|
140
|
+
parts.append(f".{node['cls']}")
|
|
141
|
+
return "/".join(parts)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _flatten(node: Optional[Dict], depth: int = 0, max_depth: int = 10) -> List[Tuple[int, str, Dict]]:
|
|
145
|
+
"""Return flat list of (depth, signature, attrs) for all nodes."""
|
|
146
|
+
if node is None or depth > max_depth:
|
|
147
|
+
return []
|
|
148
|
+
result = [(depth, _node_signature(node, depth), node.get("attrs", {}))]
|
|
149
|
+
for child in node.get("children", []):
|
|
150
|
+
result.extend(_flatten(child, depth + 1, max_depth))
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _diff_snapshots(
|
|
155
|
+
old: Optional[Dict],
|
|
156
|
+
new: Optional[Dict],
|
|
157
|
+
max_depth: int,
|
|
158
|
+
threshold_added: int,
|
|
159
|
+
threshold_removed: int,
|
|
160
|
+
threshold_changed: int,
|
|
161
|
+
) -> Dict:
|
|
162
|
+
"""Compare two DOM snapshots. Returns a diff summary dict."""
|
|
163
|
+
if old is None:
|
|
164
|
+
return {"status": "new", "message": "No previous snapshot — baseline created"}
|
|
165
|
+
|
|
166
|
+
old_nodes = _flatten(old, max_depth=max_depth)
|
|
167
|
+
new_nodes = _flatten(new, max_depth=max_depth)
|
|
168
|
+
|
|
169
|
+
old_sigs = {sig for _, sig, _ in old_nodes}
|
|
170
|
+
new_sigs = {sig for _, sig, _ in new_nodes}
|
|
171
|
+
|
|
172
|
+
added = new_sigs - old_sigs
|
|
173
|
+
removed = old_sigs - new_sigs
|
|
174
|
+
|
|
175
|
+
# check attribute changes for nodes present in both
|
|
176
|
+
old_by_sig = {sig: attrs for _, sig, attrs in old_nodes}
|
|
177
|
+
new_by_sig = {sig: attrs for _, sig, attrs in new_nodes}
|
|
178
|
+
changed_attrs: List[str] = []
|
|
179
|
+
for sig in old_sigs & new_sigs:
|
|
180
|
+
if old_by_sig.get(sig) != new_by_sig.get(sig):
|
|
181
|
+
changed_attrs.append(sig)
|
|
182
|
+
|
|
183
|
+
significant = (
|
|
184
|
+
len(added) >= threshold_added
|
|
185
|
+
or len(removed) >= threshold_removed
|
|
186
|
+
or len(changed_attrs) >= threshold_changed
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
"status": "changed" if significant else "ok",
|
|
191
|
+
"added": sorted(added)[:20],
|
|
192
|
+
"removed": sorted(removed)[:20],
|
|
193
|
+
"changed_attrs": changed_attrs[:20],
|
|
194
|
+
"counts": {
|
|
195
|
+
"added": len(added),
|
|
196
|
+
"removed": len(removed),
|
|
197
|
+
"changed_attrs": len(changed_attrs),
|
|
198
|
+
"total_old": len(old_nodes),
|
|
199
|
+
"total_new": len(new_nodes),
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Public API
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def _resolve_base_url(cfg: VisualDiffConfig) -> str:
|
|
209
|
+
if cfg.base_url:
|
|
210
|
+
return cfg.base_url.rstrip("/")
|
|
211
|
+
env_var = cfg.base_url_env or "WUP_BASE_URL"
|
|
212
|
+
return os.environ.get(env_var, "").rstrip("/")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class VisualDiffer:
|
|
216
|
+
"""
|
|
217
|
+
Triggered by TestQLWatcher after a file change.
|
|
218
|
+
|
|
219
|
+
Usage::
|
|
220
|
+
|
|
221
|
+
differ = VisualDiffer(project_root, config.visual_diff)
|
|
222
|
+
results = await differ.run_for_service(service, changed_endpoints)
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
def __init__(self, project_root: str, cfg: VisualDiffConfig) -> None:
|
|
226
|
+
self.project_root = Path(project_root)
|
|
227
|
+
self.cfg = cfg
|
|
228
|
+
self.snapshot_dir = self.project_root / cfg.snapshot_dir
|
|
229
|
+
self.diff_dir = self.project_root / cfg.diff_dir
|
|
230
|
+
self.base_url = _resolve_base_url(cfg)
|
|
231
|
+
|
|
232
|
+
def _pages_for_service(self, service: str, endpoints: List[str]) -> List[str]:
|
|
233
|
+
"""Build list of full URLs to scan for this service."""
|
|
234
|
+
pages: List[str] = list(self.cfg.pages)
|
|
235
|
+
|
|
236
|
+
if self.cfg.pages_from_endpoints and endpoints:
|
|
237
|
+
pages.extend(endpoints)
|
|
238
|
+
|
|
239
|
+
if not pages:
|
|
240
|
+
pages = [f"/{service}"]
|
|
241
|
+
|
|
242
|
+
base = self.base_url or "http://localhost"
|
|
243
|
+
result = []
|
|
244
|
+
for p in pages:
|
|
245
|
+
if p.startswith("http://") or p.startswith("https://"):
|
|
246
|
+
result.append(p)
|
|
247
|
+
else:
|
|
248
|
+
result.append(f"{base}{p}")
|
|
249
|
+
return result
|
|
250
|
+
|
|
251
|
+
async def run_for_service(
|
|
252
|
+
self, service: str, endpoints: List[str]
|
|
253
|
+
) -> List[Dict[str, Any]]:
|
|
254
|
+
"""
|
|
255
|
+
Scan pages for *service*, diff against stored snapshots.
|
|
256
|
+
Returns list of diff results (one per page).
|
|
257
|
+
"""
|
|
258
|
+
if not self.cfg.enabled:
|
|
259
|
+
return []
|
|
260
|
+
|
|
261
|
+
if self.cfg.delay_seconds > 0:
|
|
262
|
+
await asyncio.sleep(self.cfg.delay_seconds)
|
|
263
|
+
|
|
264
|
+
pages = self._pages_for_service(service, endpoints)
|
|
265
|
+
results = []
|
|
266
|
+
for url in pages:
|
|
267
|
+
result = await self._check_page(service, url)
|
|
268
|
+
results.append(result)
|
|
269
|
+
if result["diff"]["status"] == "changed":
|
|
270
|
+
self._write_diff_event(service, url, result)
|
|
271
|
+
console.print(
|
|
272
|
+
f"[bold yellow]🔍 Visual diff: {service} {url}[/bold yellow] "
|
|
273
|
+
f"+{result['diff']['counts']['added']} "
|
|
274
|
+
f"-{result['diff']['counts']['removed']} "
|
|
275
|
+
f"~{result['diff']['counts']['changed_attrs']}"
|
|
276
|
+
)
|
|
277
|
+
elif result["diff"]["status"] == "new":
|
|
278
|
+
console.print(f"[dim]📷 Baseline snapshot: {url}[/dim]")
|
|
279
|
+
else:
|
|
280
|
+
console.print(f"[dim green]✓ No DOM change: {url}[/dim green]")
|
|
281
|
+
|
|
282
|
+
return results
|
|
283
|
+
|
|
284
|
+
async def _check_page(self, service: str, url: str) -> Dict[str, Any]:
|
|
285
|
+
snap_path = _snapshot_path(self.snapshot_dir, service, url)
|
|
286
|
+
old_snapshot = _load_snapshot(snap_path)
|
|
287
|
+
|
|
288
|
+
new_snapshot = await _fetch_dom_snapshot(url, self.cfg.max_depth, self.cfg.headless)
|
|
289
|
+
|
|
290
|
+
if new_snapshot is None:
|
|
291
|
+
return {"url": url, "diff": {"status": "error", "message": "Failed to fetch page"}}
|
|
292
|
+
|
|
293
|
+
diff = _diff_snapshots(
|
|
294
|
+
old_snapshot,
|
|
295
|
+
new_snapshot,
|
|
296
|
+
self.cfg.max_depth,
|
|
297
|
+
self.cfg.threshold_added,
|
|
298
|
+
self.cfg.threshold_removed,
|
|
299
|
+
self.cfg.threshold_changed,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# always update snapshot (new baseline)
|
|
303
|
+
_save_snapshot(snap_path, new_snapshot)
|
|
304
|
+
|
|
305
|
+
return {"url": url, "diff": diff, "timestamp": int(time.time())}
|
|
306
|
+
|
|
307
|
+
def _write_diff_event(self, service: str, url: str, result: Dict) -> None:
|
|
308
|
+
slug = _page_slug(url)
|
|
309
|
+
svc_safe = service.replace("/", "_")
|
|
310
|
+
diff_file = self.diff_dir / svc_safe / f"{slug}.jsonl"
|
|
311
|
+
diff_file.parent.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
entry = {"service": service, "url": url, **result}
|
|
313
|
+
with diff_file.open("a", encoding="utf-8") as fh:
|
|
314
|
+
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
315
|
+
|
|
316
|
+
def get_recent_diffs(self, seconds: int = 300) -> List[Dict]:
|
|
317
|
+
"""Return all diff events newer than *seconds* ago."""
|
|
318
|
+
cutoff = int(time.time()) - seconds
|
|
319
|
+
results = []
|
|
320
|
+
if not self.diff_dir.exists():
|
|
321
|
+
return results
|
|
322
|
+
for jsonl_file in self.diff_dir.rglob("*.jsonl"):
|
|
323
|
+
try:
|
|
324
|
+
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
|
325
|
+
if not line.strip():
|
|
326
|
+
continue
|
|
327
|
+
entry = json.loads(line)
|
|
328
|
+
if entry.get("timestamp", 0) >= cutoff:
|
|
329
|
+
results.append(entry)
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
results.sort(key=lambda e: e.get("timestamp", 0), reverse=True)
|
|
333
|
+
return results
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wup
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.14
|
|
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: Apache-2.0
|
|
@@ -29,17 +29,17 @@ Dynamic: license-file
|
|
|
29
29
|
|
|
30
30
|
## AI Cost Tracking
|
|
31
31
|
|
|
32
|
-
    
|
|
33
|
+
  
|
|
34
34
|
|
|
35
|
-
- 🤖 **LLM usage:** $
|
|
36
|
-
- 👤 **Human dev:** ~$
|
|
35
|
+
- 🤖 **LLM usage:** $2.2500 (15 commits)
|
|
36
|
+
- 👤 **Human dev:** ~$324 (3.2h @ $100/h, 30min dedup)
|
|
37
37
|
|
|
38
38
|
Generated on 2026-04-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
-
    
|
|
43
43
|
|
|
44
44
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
45
45
|
|
|
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
|