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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wup
3
- Version: 0.2.12
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.12-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-$1.95-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-3.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
32
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.14-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.25-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-3.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
34
34
 
35
- - 🤖 **LLM usage:** $1.9500 (13 commits)
36
- - 👤 **Human dev:** ~$312 (3.1h @ $100/h, 30min dedup)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.12-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
42
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.14-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.12-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-$1.95-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-3.1h-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.14-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.25-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-3.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $1.9500 (13 commits)
10
- - 👤 **Human dev:** ~$312 (3.1h @ $100/h, 30min dedup)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.12-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.14-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.12"
7
+ version = "0.2.14"
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"
@@ -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.12"
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.12
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.12-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-$1.95-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-3.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
32
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.14-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.25-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-3.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
34
34
 
35
- - 🤖 **LLM usage:** $1.9500 (13 commits)
36
- - 👤 **Human dev:** ~$312 (3.1h @ $100/h, 30min dedup)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.12-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
42
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.14-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
43
43
 
44
44
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
45
45
 
@@ -11,6 +11,7 @@ wup/core.py
11
11
  wup/dependency_mapper.py
12
12
  wup/testql_discovery.py
13
13
  wup/testql_watcher.py
14
+ wup/visual_diff.py
14
15
  wup.egg-info/PKG-INFO
15
16
  wup.egg-info/SOURCES.txt
16
17
  wup.egg-info/dependency_links.txt
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