wup 0.2.67__tar.gz → 0.2.69__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.67/wup.egg-info → wup-0.2.69}/PKG-INFO +7 -5
- {wup-0.2.67 → wup-0.2.69}/README.md +6 -4
- {wup-0.2.67 → wup-0.2.69}/pyproject.toml +1 -1
- wup-0.2.69/tests/test_planfile_reporter_dedupe.py +78 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_testql_watcher.py +41 -4
- wup-0.2.69/tests/test_watch_no_paths_exit.py +30 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_wup.py +98 -8
- {wup-0.2.67 → wup-0.2.69}/wup/__init__.py +1 -1
- {wup-0.2.67 → wup-0.2.69}/wup/cli.py +2 -1
- {wup-0.2.67 → wup-0.2.69}/wup/config.py +6 -0
- {wup-0.2.67 → wup-0.2.69}/wup/core.py +25 -16
- {wup-0.2.67 → wup-0.2.69}/wup/models/__init__.py +2 -0
- {wup-0.2.67 → wup-0.2.69}/wup/models/config.py +3 -0
- wup-0.2.69/wup/models/target.py +23 -0
- {wup-0.2.67 → wup-0.2.69}/wup/planfile_reporter.py +55 -2
- {wup-0.2.67 → wup-0.2.69}/wup/testql_watcher.py +20 -9
- {wup-0.2.67 → wup-0.2.69}/wup/visual_diff.py +62 -5
- {wup-0.2.67 → wup-0.2.69/wup.egg-info}/PKG-INFO +7 -5
- {wup-0.2.67 → wup-0.2.69}/wup.egg-info/SOURCES.txt +3 -0
- {wup-0.2.67 → wup-0.2.69}/LICENSE +0 -0
- {wup-0.2.67 → wup-0.2.69}/setup.cfg +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_assistant.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_auto_detection.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_cli_bridge.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_cli_filtering.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_control.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_e2e.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_endpoints_init_cli.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_health_summary_passed.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_monitoring_manifest.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_probe_mutex.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_service_inference.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_status_data.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_sync.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_testql_monitor.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_visual_diff_periodic_skip.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_visual_diff_progress.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_watch_exclude.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_web_client.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/tests/test_wup_generate.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/_ast_detector.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/_base_detector.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/_hash_detector.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/_yaml_detector.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/anomaly_detector.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/anomaly_models.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/assistant.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/assistant_discovery.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/assistant_validator.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/bus.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/cli_bridge.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/cli_config_generator.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/cli_scanner.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/control.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/dependency_mapper.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/endpoints.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/event_store.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/file_watcher/events/file_events.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/generate.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/init_cli.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/monitoring_manifest.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/paths.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/status_data.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/sync.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/testing/events/health_events.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/testing/events/test_results.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/testing/handlers/event_handlers.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/testing/handlers/health_handlers.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/testing/queries/health_queries.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/testql_cli_generator.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/testql_discovery.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/testql_monitor.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/validate.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup/web_client.py +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.67 → wup-0.2.69}/wup.egg-info/requires.txt +0 -0
- {wup-0.2.67 → wup-0.2.69}/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.69
|
|
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
|
|
@@ -42,15 +42,17 @@ Dynamic: license-file
|
|
|
42
42
|
|
|
43
43
|
## AI Cost Tracking
|
|
44
44
|
|
|
45
|
-
   
|
|
46
|
+
  
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
- 🤖 **LLM usage:** $5.8610 (97 commits)
|
|
49
|
+
- 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
|
|
48
50
|
|
|
49
|
-
Generated on 2026-
|
|
51
|
+
Generated on 2026-07-16 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
50
52
|
|
|
51
53
|
---
|
|
52
54
|
|
|
53
|
-
    
|
|
54
56
|
|
|
55
57
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
56
58
|
|
|
@@ -3,15 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
   
|
|
7
|
+
  
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
- 🤖 **LLM usage:** $5.8610 (97 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
|
|
9
11
|
|
|
10
|
-
Generated on 2026-
|
|
12
|
+
Generated on 2026-07-16 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
11
13
|
|
|
12
14
|
---
|
|
13
15
|
|
|
14
|
-
    
|
|
15
17
|
|
|
16
18
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
17
19
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Regression: dedupe registry must not mute recurrences of CLOSED tickets.
|
|
2
|
+
|
|
3
|
+
A stale `.wup/planfile-tickets.json` entry whose ticket was already
|
|
4
|
+
done/canceled used to suppress every future ticket for the same
|
|
5
|
+
failure fingerprint — a regression of an old outage became silently
|
|
6
|
+
invisible (found live: `docker stop connect-scenario` produced no ticket
|
|
7
|
+
because PLF-1950 from a month earlier still held the fingerprint).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from types import SimpleNamespace
|
|
15
|
+
|
|
16
|
+
from wup import planfile_reporter as reporter_mod
|
|
17
|
+
from wup.models.config import PlanfileConfig
|
|
18
|
+
from wup.planfile_reporter import PlanfileReporter
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _reporter(tmp_path: Path) -> PlanfileReporter:
|
|
22
|
+
cfg = PlanfileConfig(enabled=True, command="planfile")
|
|
23
|
+
return PlanfileReporter(tmp_path, cfg)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _seed_dedupe(rep: PlanfileReporter, fingerprint_kwargs: dict, ticket_id: str) -> None:
|
|
27
|
+
fingerprint = rep._fingerprint(**fingerprint_kwargs)
|
|
28
|
+
rep.dedupe_path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
rep.dedupe_path.write_text(
|
|
30
|
+
json.dumps({fingerprint: {"ticket_id": ticket_id, "service": fingerprint_kwargs["service"], "stage": fingerprint_kwargs["stage"]}}),
|
|
31
|
+
encoding="utf-8",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
FAIL = {"service": "svc", "status": "down", "stage": "probe", "message": "connection refused"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_open_ticket_still_mutes_recurrence(tmp_path, monkeypatch):
|
|
39
|
+
rep = _reporter(tmp_path)
|
|
40
|
+
_seed_dedupe(rep, FAIL, "PLF-1")
|
|
41
|
+
|
|
42
|
+
def fake_run(cmd, **kwargs):
|
|
43
|
+
assert "show" in cmd
|
|
44
|
+
return SimpleNamespace(returncode=0, stdout=json.dumps({"id": "PLF-1", "status": "open"}), stderr="")
|
|
45
|
+
|
|
46
|
+
monkeypatch.setattr(reporter_mod.subprocess, "run", fake_run)
|
|
47
|
+
assert rep.report_failure(**FAIL) == "PLF-1"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_closed_ticket_refiles_fresh_ticket(tmp_path, monkeypatch):
|
|
51
|
+
rep = _reporter(tmp_path)
|
|
52
|
+
_seed_dedupe(rep, FAIL, "PLF-1")
|
|
53
|
+
calls = []
|
|
54
|
+
|
|
55
|
+
def fake_run(cmd, **kwargs):
|
|
56
|
+
calls.append(list(cmd))
|
|
57
|
+
if "show" in cmd:
|
|
58
|
+
return SimpleNamespace(returncode=0, stdout=json.dumps({"id": "PLF-1", "status": "done"}), stderr="")
|
|
59
|
+
return SimpleNamespace(returncode=0, stdout="✓ Created PLF-2: [AUTO-DIAG] wup-svc probe down", stderr="")
|
|
60
|
+
|
|
61
|
+
monkeypatch.setattr(reporter_mod.subprocess, "run", fake_run)
|
|
62
|
+
monkeypatch.setattr(rep, "_wait_for_planfile_store_ready", lambda timeout_s=30.0: True)
|
|
63
|
+
|
|
64
|
+
assert rep.report_failure(**FAIL) == "PLF-2"
|
|
65
|
+
# registry now points at the fresh ticket
|
|
66
|
+
dedupe = json.loads(rep.dedupe_path.read_text(encoding="utf-8"))
|
|
67
|
+
assert [v["ticket_id"] for v in dedupe.values()] == ["PLF-2"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_show_error_keeps_muting_conservatively(tmp_path, monkeypatch):
|
|
71
|
+
rep = _reporter(tmp_path)
|
|
72
|
+
_seed_dedupe(rep, FAIL, "PLF-1")
|
|
73
|
+
|
|
74
|
+
def fake_run(cmd, **kwargs):
|
|
75
|
+
return SimpleNamespace(returncode=1, stdout="", stderr="boom")
|
|
76
|
+
|
|
77
|
+
monkeypatch.setattr(reporter_mod.subprocess, "run", fake_run)
|
|
78
|
+
assert rep.report_failure(**FAIL) == "PLF-1"
|
|
@@ -246,7 +246,10 @@ def test_planfile_reporter_creates_deduped_ticket(monkeypatch):
|
|
|
246
246
|
|
|
247
247
|
assert first == "PLF-999"
|
|
248
248
|
assert second == "PLF-999"
|
|
249
|
-
|
|
249
|
+
# Recurrence check may `ticket show` the deduped id, but only ONE
|
|
250
|
+
# ticket may ever be created for the same open fingerprint.
|
|
251
|
+
create_calls = [c for c in calls if c[:3] == ["planfile", "ticket", "create"]]
|
|
252
|
+
assert len(create_calls) == 1
|
|
250
253
|
assert calls[0][:3] == ["planfile", "ticket", "create"]
|
|
251
254
|
assert "--label" in calls[0]
|
|
252
255
|
assert "llm-ready" in calls[0]
|
|
@@ -283,6 +286,34 @@ def test_planfile_reporter_clears_dedupe_after_recovery(monkeypatch):
|
|
|
283
286
|
|
|
284
287
|
assert first == "PLF-1000"
|
|
285
288
|
assert second == "PLF-1001"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_planfile_reporter_retries_without_files_for_old_planfile_cli(monkeypatch):
|
|
292
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
293
|
+
root = Path(tmpdir)
|
|
294
|
+
calls = []
|
|
295
|
+
|
|
296
|
+
def fake_run(cmd, **kwargs):
|
|
297
|
+
calls.append(cmd)
|
|
298
|
+
if "--files" in cmd:
|
|
299
|
+
return CompletedProcess(cmd, returncode=2, stdout="", stderr="Error: No such option: --files")
|
|
300
|
+
return CompletedProcess(cmd, returncode=0, stdout="Created PLF-998\n", stderr="")
|
|
301
|
+
|
|
302
|
+
monkeypatch.setattr("wup.planfile_reporter.subprocess.run", fake_run)
|
|
303
|
+
reporter = PlanfileReporter(root, PlanfileConfig(enabled=True))
|
|
304
|
+
|
|
305
|
+
ticket_id = reporter.report_failure(
|
|
306
|
+
service="firmware",
|
|
307
|
+
status="failed",
|
|
308
|
+
stage="detail",
|
|
309
|
+
message="detail failed",
|
|
310
|
+
track_file=".wup/tracks/firmware_detail.json",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
assert ticket_id == "PLF-998"
|
|
314
|
+
assert len(calls) == 2
|
|
315
|
+
assert "--files" in calls[0]
|
|
316
|
+
assert "--files" not in calls[1]
|
|
286
317
|
assert len(calls) == 2
|
|
287
318
|
|
|
288
319
|
|
|
@@ -414,7 +445,13 @@ def test_visual_differ_disabled_by_default():
|
|
|
414
445
|
# Differ is created but flagged disabled — run_for_service() must be a no-op
|
|
415
446
|
assert watcher.visual_differ is not None
|
|
416
447
|
assert watcher.visual_differ.cfg.enabled is False
|
|
417
|
-
|
|
448
|
+
from wup.models.target import ServiceTestTarget
|
|
449
|
+
|
|
450
|
+
results = asyncio.run(
|
|
451
|
+
watcher.visual_differ.run_for_service(
|
|
452
|
+
ServiceTestTarget(service="svc", endpoints=["/x"])
|
|
453
|
+
)
|
|
454
|
+
)
|
|
418
455
|
assert results == []
|
|
419
456
|
|
|
420
457
|
|
|
@@ -504,8 +541,8 @@ def test_quick_pass_actions_prefer_config_endpoints_for_visual_diff():
|
|
|
504
541
|
self.cfg = VisualDiffConfig(enabled=True)
|
|
505
542
|
self.calls = []
|
|
506
543
|
|
|
507
|
-
async def run_for_service(self,
|
|
508
|
-
self.calls.append((service, list(endpoints)))
|
|
544
|
+
async def run_for_service(self, target):
|
|
545
|
+
self.calls.append((target.service, list(target.endpoints)))
|
|
509
546
|
return []
|
|
510
547
|
|
|
511
548
|
differ = RecordingDiffer()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Regression: a watcher that never watched must not exit as a clean run.
|
|
2
|
+
|
|
3
|
+
`start_watching` used to bare-`return` when every configured watch path was
|
|
4
|
+
missing — under systemd (Restart=on-failure) the clean exit 0 meant the unit
|
|
5
|
+
silently died and never came back. It now returns False and the CLI turns
|
|
6
|
+
that into exit code 1.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from wup.core import WupWatcher
|
|
12
|
+
from wup.models.config import ProjectConfig, WatchConfig, WupConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _watcher_with_paths(paths: list[str]) -> WupWatcher:
|
|
16
|
+
cfg = WupConfig(
|
|
17
|
+
project=ProjectConfig(name="t"),
|
|
18
|
+
watch=WatchConfig(paths=paths),
|
|
19
|
+
)
|
|
20
|
+
return WupWatcher(project_root="/nonexistent-wup-test-root", config=cfg)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_start_watching_returns_false_when_no_valid_paths() -> None:
|
|
24
|
+
watcher = _watcher_with_paths(["does-not-exist/**"])
|
|
25
|
+
assert watcher.start_watching() is False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_start_watching_returns_false_for_explicit_missing_paths() -> None:
|
|
29
|
+
watcher = _watcher_with_paths([])
|
|
30
|
+
assert watcher.start_watching(["/nonexistent-wup-test-root/a"]) is False
|
|
@@ -11,6 +11,7 @@ import pytest
|
|
|
11
11
|
from wup.config import load_config, save_config, get_default_config
|
|
12
12
|
from wup.core import WupWatcher
|
|
13
13
|
from wup.dependency_mapper import DependencyMapper
|
|
14
|
+
from wup.models.target import ServiceTestTarget
|
|
14
15
|
from wup.models.config import (
|
|
15
16
|
WupConfig,
|
|
16
17
|
WatchConfig,
|
|
@@ -26,6 +27,7 @@ from wup.models.config import (
|
|
|
26
27
|
from wup.testql_watcher import TestQLWatcher
|
|
27
28
|
from wup.visual_diff import (
|
|
28
29
|
VisualDiffer,
|
|
30
|
+
_chromium_launch_options,
|
|
29
31
|
_diff_snapshots,
|
|
30
32
|
_looks_like_visual_page,
|
|
31
33
|
_page_slug,
|
|
@@ -994,6 +996,9 @@ class TestConfigModels:
|
|
|
994
996
|
assert cfg.pages == []
|
|
995
997
|
assert cfg.pages_from_endpoints is True
|
|
996
998
|
assert cfg.headless is True
|
|
999
|
+
assert cfg.page_settle_ms == 750
|
|
1000
|
+
assert cfg.issue_retry_count == 0
|
|
1001
|
+
assert cfg.issue_retry_delay_seconds == 2.0
|
|
997
1002
|
|
|
998
1003
|
def test_visual_diff_config_custom(self):
|
|
999
1004
|
"""Test VisualDiffConfig with custom values."""
|
|
@@ -1002,11 +1007,17 @@ class TestConfigModels:
|
|
|
1002
1007
|
base_url="http://localhost:9000",
|
|
1003
1008
|
pages=["/dashboard", "/users"],
|
|
1004
1009
|
threshold_added=10,
|
|
1010
|
+
page_settle_ms=1500,
|
|
1011
|
+
issue_retry_count=2,
|
|
1012
|
+
issue_retry_delay_seconds=3.0,
|
|
1005
1013
|
)
|
|
1006
1014
|
assert cfg.enabled is True
|
|
1007
1015
|
assert cfg.base_url == "http://localhost:9000"
|
|
1008
1016
|
assert cfg.pages == ["/dashboard", "/users"]
|
|
1009
1017
|
assert cfg.threshold_added == 10
|
|
1018
|
+
assert cfg.page_settle_ms == 1500
|
|
1019
|
+
assert cfg.issue_retry_count == 2
|
|
1020
|
+
assert cfg.issue_retry_delay_seconds == 3.0
|
|
1010
1021
|
|
|
1011
1022
|
|
|
1012
1023
|
class TestVisualDiffer:
|
|
@@ -1026,6 +1037,14 @@ class TestVisualDiffer:
|
|
|
1026
1037
|
monkeypatch.delenv("WUP_VD_NONE", raising=False)
|
|
1027
1038
|
assert _resolve_base_url(cfg) == ""
|
|
1028
1039
|
|
|
1040
|
+
def test_chromium_launch_options_uses_env_executable(self, tmp_path, monkeypatch):
|
|
1041
|
+
chrome = tmp_path / "chrome"
|
|
1042
|
+
chrome.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
1043
|
+
monkeypatch.setenv("WUP_CHROMIUM_EXECUTABLE_PATH", str(chrome))
|
|
1044
|
+
options = _chromium_launch_options(True)
|
|
1045
|
+
assert options["headless"] is True
|
|
1046
|
+
assert options["executable_path"] == str(chrome)
|
|
1047
|
+
|
|
1029
1048
|
def test_page_slug(self):
|
|
1030
1049
|
assert _page_slug("http://x/api/v1/users") == "api_v1_users"
|
|
1031
1050
|
assert _page_slug("http://x/") == "root"
|
|
@@ -1038,7 +1057,7 @@ class TestVisualDiffer:
|
|
|
1038
1057
|
pages_from_endpoints=False,
|
|
1039
1058
|
)
|
|
1040
1059
|
differ = VisualDiffer(tmpdir, cfg)
|
|
1041
|
-
pages = differ._pages_for_service("users", ["/api/users"])
|
|
1060
|
+
pages = differ._pages_for_service(ServiceTestTarget(service="users", endpoints=["/api/users"]))
|
|
1042
1061
|
assert pages == ["http://localhost:8080/dashboard"]
|
|
1043
1062
|
|
|
1044
1063
|
def test_pages_for_service_from_endpoints(self):
|
|
@@ -1048,7 +1067,9 @@ class TestVisualDiffer:
|
|
|
1048
1067
|
pages_from_endpoints=True,
|
|
1049
1068
|
)
|
|
1050
1069
|
differ = VisualDiffer(tmpdir, cfg)
|
|
1051
|
-
pages = differ._pages_for_service(
|
|
1070
|
+
pages = differ._pages_for_service(
|
|
1071
|
+
ServiceTestTarget(service="users", endpoints=["/connect-users", "/connect-users/1"])
|
|
1072
|
+
)
|
|
1052
1073
|
assert "http://localhost:8080/connect-users" in pages
|
|
1053
1074
|
assert "http://localhost:8080/connect-users/1" in pages
|
|
1054
1075
|
|
|
@@ -1065,8 +1086,10 @@ class TestVisualDiffer:
|
|
|
1065
1086
|
)
|
|
1066
1087
|
differ = VisualDiffer(tmpdir, cfg)
|
|
1067
1088
|
pages = differ._pages_for_service(
|
|
1068
|
-
|
|
1069
|
-
|
|
1089
|
+
ServiceTestTarget(
|
|
1090
|
+
service="backend",
|
|
1091
|
+
endpoints=["/api/v3/health", "/connect-config", "http://localhost:8080/connect-data"],
|
|
1092
|
+
)
|
|
1070
1093
|
)
|
|
1071
1094
|
assert "http://localhost:8080/api/v3/health" not in pages
|
|
1072
1095
|
assert "http://localhost:8080/connect-config" in pages
|
|
@@ -1079,7 +1102,7 @@ class TestVisualDiffer:
|
|
|
1079
1102
|
pages_from_endpoints=False,
|
|
1080
1103
|
)
|
|
1081
1104
|
differ = VisualDiffer(tmpdir, cfg)
|
|
1082
|
-
pages = differ._pages_for_service("users"
|
|
1105
|
+
pages = differ._pages_for_service(ServiceTestTarget(service="users"))
|
|
1083
1106
|
assert pages == ["http://localhost:8080/users"]
|
|
1084
1107
|
|
|
1085
1108
|
def test_pages_for_service_absolute_url_passthrough(self):
|
|
@@ -1090,7 +1113,7 @@ class TestVisualDiffer:
|
|
|
1090
1113
|
pages_from_endpoints=False,
|
|
1091
1114
|
)
|
|
1092
1115
|
differ = VisualDiffer(tmpdir, cfg)
|
|
1093
|
-
pages = differ._pages_for_service("svc"
|
|
1116
|
+
pages = differ._pages_for_service(ServiceTestTarget(service="svc"))
|
|
1094
1117
|
assert pages == ["https://example.com/connect-dashboard"]
|
|
1095
1118
|
|
|
1096
1119
|
def test_diff_snapshots_baseline(self):
|
|
@@ -1126,7 +1149,9 @@ class TestVisualDiffer:
|
|
|
1126
1149
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
1127
1150
|
cfg = VisualDiffConfig(enabled=False)
|
|
1128
1151
|
differ = VisualDiffer(tmpdir, cfg)
|
|
1129
|
-
results = asyncio.run(
|
|
1152
|
+
results = asyncio.run(
|
|
1153
|
+
differ.run_for_service(ServiceTestTarget(service="svc", endpoints=["/x"]))
|
|
1154
|
+
)
|
|
1130
1155
|
assert results == []
|
|
1131
1156
|
|
|
1132
1157
|
def test_run_for_service_summarizes_fetch_errors(self, monkeypatch):
|
|
@@ -1159,7 +1184,9 @@ class TestVisualDiffer:
|
|
|
1159
1184
|
monkeypatch.setattr(visual_diff_module.console, "print", lambda message: printed.append(str(message)))
|
|
1160
1185
|
|
|
1161
1186
|
results = asyncio.run(
|
|
1162
|
-
differ.run_for_service(
|
|
1187
|
+
differ.run_for_service(
|
|
1188
|
+
ServiceTestTarget(service="frontend", endpoints=["/a", "/b", "/c", "/d"])
|
|
1189
|
+
)
|
|
1163
1190
|
)
|
|
1164
1191
|
|
|
1165
1192
|
assert len(results) == 4
|
|
@@ -1168,6 +1195,57 @@ class TestVisualDiffer:
|
|
|
1168
1195
|
assert "/a, /b, /c (+1 more)" in printed[0]
|
|
1169
1196
|
assert "4x BrowserType.launch: Executable doesn't exist at /tmp/chrome" in printed[0]
|
|
1170
1197
|
|
|
1198
|
+
def test_check_page_retries_transient_visual_issue(self, monkeypatch):
|
|
1199
|
+
"""A transient Vite overlay/tiny shell should be retried before reporting an issue."""
|
|
1200
|
+
import asyncio
|
|
1201
|
+
from wup import visual_diff as visual_diff_module
|
|
1202
|
+
|
|
1203
|
+
bad_snapshot = {
|
|
1204
|
+
"tag": "HTML",
|
|
1205
|
+
"meta": {
|
|
1206
|
+
"text_length": 33,
|
|
1207
|
+
"dom_nodes": 8,
|
|
1208
|
+
"matched_error_selectors": ["vite-error-overlay"],
|
|
1209
|
+
},
|
|
1210
|
+
}
|
|
1211
|
+
good_snapshot = {
|
|
1212
|
+
"tag": "HTML",
|
|
1213
|
+
"children": [{"tag": "BODY", "children": [{"tag": "MAIN"}]}],
|
|
1214
|
+
"meta": {
|
|
1215
|
+
"text_length": 500,
|
|
1216
|
+
"dom_nodes": 50,
|
|
1217
|
+
"matched_error_selectors": [],
|
|
1218
|
+
},
|
|
1219
|
+
}
|
|
1220
|
+
snapshots = [bad_snapshot, good_snapshot]
|
|
1221
|
+
calls = []
|
|
1222
|
+
|
|
1223
|
+
async def fake_fetch(*args):
|
|
1224
|
+
calls.append(args)
|
|
1225
|
+
return snapshots.pop(0), None
|
|
1226
|
+
|
|
1227
|
+
async def fake_sleep(_seconds):
|
|
1228
|
+
return None
|
|
1229
|
+
|
|
1230
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
1231
|
+
cfg = VisualDiffConfig(
|
|
1232
|
+
enabled=True,
|
|
1233
|
+
issue_retry_count=1,
|
|
1234
|
+
issue_retry_delay_seconds=0,
|
|
1235
|
+
page_settle_ms=1234,
|
|
1236
|
+
)
|
|
1237
|
+
differ = VisualDiffer(tmpdir, cfg)
|
|
1238
|
+
|
|
1239
|
+
monkeypatch.setattr(visual_diff_module, "_fetch_dom_snapshot", fake_fetch)
|
|
1240
|
+
monkeypatch.setattr(visual_diff_module.asyncio, "sleep", fake_sleep)
|
|
1241
|
+
|
|
1242
|
+
result = asyncio.run(differ._check_page("frontend", "http://localhost/page"))
|
|
1243
|
+
|
|
1244
|
+
assert len(calls) == 2
|
|
1245
|
+
assert calls[0][-1] == 1234
|
|
1246
|
+
assert result["diff"]["status"] in {"new", "ok", "changed"}
|
|
1247
|
+
assert "issues" not in result["diff"]
|
|
1248
|
+
|
|
1171
1249
|
def test_get_recent_diffs_empty(self):
|
|
1172
1250
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
1173
1251
|
cfg = VisualDiffConfig()
|
|
@@ -1376,6 +1454,9 @@ testql:
|
|
|
1376
1454
|
threshold_added=7,
|
|
1377
1455
|
threshold_removed=4,
|
|
1378
1456
|
threshold_changed=8,
|
|
1457
|
+
page_settle_ms=1600,
|
|
1458
|
+
issue_retry_count=3,
|
|
1459
|
+
issue_retry_delay_seconds=4.5,
|
|
1379
1460
|
headless=False,
|
|
1380
1461
|
),
|
|
1381
1462
|
)
|
|
@@ -1392,6 +1473,9 @@ testql:
|
|
|
1392
1473
|
assert vd.threshold_added == 7
|
|
1393
1474
|
assert vd.threshold_removed == 4
|
|
1394
1475
|
assert vd.threshold_changed == 8
|
|
1476
|
+
assert vd.page_settle_ms == 1600
|
|
1477
|
+
assert vd.issue_retry_count == 3
|
|
1478
|
+
assert vd.issue_retry_delay_seconds == 4.5
|
|
1395
1479
|
assert vd.headless is False
|
|
1396
1480
|
|
|
1397
1481
|
def test_load_config_visual_diff_from_yaml(self):
|
|
@@ -1415,6 +1499,9 @@ visual_diff:
|
|
|
1415
1499
|
threshold_added: 5
|
|
1416
1500
|
threshold_removed: 2
|
|
1417
1501
|
threshold_changed: 9
|
|
1502
|
+
page_settle_ms: 1500
|
|
1503
|
+
issue_retry_count: 2
|
|
1504
|
+
issue_retry_delay_seconds: 3
|
|
1418
1505
|
headless: false
|
|
1419
1506
|
"""
|
|
1420
1507
|
config_path = Path(tmpdir) / "wup.yaml"
|
|
@@ -1433,6 +1520,9 @@ visual_diff:
|
|
|
1433
1520
|
assert vd.threshold_added == 5
|
|
1434
1521
|
assert vd.threshold_removed == 2
|
|
1435
1522
|
assert vd.threshold_changed == 9
|
|
1523
|
+
assert vd.page_settle_ms == 1500
|
|
1524
|
+
assert vd.issue_retry_count == 2
|
|
1525
|
+
assert vd.issue_retry_delay_seconds == 3.0
|
|
1436
1526
|
assert vd.headless is False
|
|
1437
1527
|
|
|
1438
1528
|
def test_load_config_visual_diff_defaults_when_section_absent(self):
|
|
@@ -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.69"
|
|
11
11
|
__author__ = "Tom Sapletta"
|
|
12
12
|
|
|
13
13
|
from .config import load_config, save_config, get_default_config
|
|
@@ -198,7 +198,8 @@ def watch(
|
|
|
198
198
|
asyncio.run(watcher.run_with_dashboard())
|
|
199
199
|
else:
|
|
200
200
|
console.print("[green]Starting watcher...[/green]")
|
|
201
|
-
watcher.start_watching()
|
|
201
|
+
if watcher.start_watching() is False:
|
|
202
|
+
raise typer.Exit(1)
|
|
202
203
|
|
|
203
204
|
|
|
204
205
|
def _auto_generate_config(project_path: Path, mode: str):
|
|
@@ -292,6 +292,9 @@ def _parse_visual_diff_config(raw: dict) -> VisualDiffConfig:
|
|
|
292
292
|
threshold_changed=int(vd_raw.get("threshold_changed", 5)),
|
|
293
293
|
min_text_length=int(vd_raw.get("min_text_length", 200)),
|
|
294
294
|
min_dom_nodes=int(vd_raw.get("min_dom_nodes", 20)),
|
|
295
|
+
page_settle_ms=int(vd_raw.get("page_settle_ms", 750)),
|
|
296
|
+
issue_retry_count=int(vd_raw.get("issue_retry_count", 0)),
|
|
297
|
+
issue_retry_delay_seconds=float(vd_raw.get("issue_retry_delay_seconds", 2.0)),
|
|
295
298
|
error_selectors=vd_raw.get("error_selectors", [
|
|
296
299
|
"#error-container",
|
|
297
300
|
".error-container",
|
|
@@ -494,6 +497,9 @@ def save_config(config: WupConfig, output_path: Path):
|
|
|
494
497
|
"threshold_changed": config.visual_diff.threshold_changed,
|
|
495
498
|
"min_text_length": config.visual_diff.min_text_length,
|
|
496
499
|
"min_dom_nodes": config.visual_diff.min_dom_nodes,
|
|
500
|
+
"page_settle_ms": config.visual_diff.page_settle_ms,
|
|
501
|
+
"issue_retry_count": config.visual_diff.issue_retry_count,
|
|
502
|
+
"issue_retry_delay_seconds": config.visual_diff.issue_retry_delay_seconds,
|
|
497
503
|
"error_selectors": config.visual_diff.error_selectors,
|
|
498
504
|
"headless": config.visual_diff.headless,
|
|
499
505
|
},
|
|
@@ -25,6 +25,7 @@ from watchdog.observers.polling import PollingObserver
|
|
|
25
25
|
from .config import load_config
|
|
26
26
|
from .dependency_mapper import DependencyMapper
|
|
27
27
|
from .models.config import WupConfig, ServiceConfig
|
|
28
|
+
from .models.target import ServiceTestTarget
|
|
28
29
|
from .planfile_reporter import PlanfileReporter
|
|
29
30
|
|
|
30
31
|
|
|
@@ -278,14 +279,15 @@ class WupWatcher:
|
|
|
278
279
|
return
|
|
279
280
|
|
|
280
281
|
test_type, service, endpoints = self.test_queue.popleft()
|
|
282
|
+
target = ServiceTestTarget(service, endpoints)
|
|
281
283
|
|
|
282
284
|
try:
|
|
283
285
|
if test_type == "quick":
|
|
284
|
-
passed = await self.run_quick_test(
|
|
286
|
+
passed = await self.run_quick_test(target)
|
|
285
287
|
if not passed:
|
|
286
288
|
self.schedule_detail_test(service)
|
|
287
289
|
elif test_type == "detail":
|
|
288
|
-
await self.run_detail_test(
|
|
290
|
+
await self.run_detail_test(target)
|
|
289
291
|
except Exception as e:
|
|
290
292
|
self.console.print(f"[red]Error testing {service}: {e}[/red]")
|
|
291
293
|
|
|
@@ -302,17 +304,17 @@ class WupWatcher:
|
|
|
302
304
|
except Exception:
|
|
303
305
|
return True
|
|
304
306
|
|
|
305
|
-
async def run_quick_test(self,
|
|
307
|
+
async def run_quick_test(self, target: ServiceTestTarget) -> bool:
|
|
306
308
|
"""
|
|
307
309
|
Run a quick test for a service (smoke test).
|
|
308
310
|
|
|
309
311
|
Args:
|
|
310
|
-
|
|
311
|
-
endpoints: List of endpoints to test
|
|
312
|
+
target: Service and endpoints to test
|
|
312
313
|
|
|
313
314
|
Returns:
|
|
314
315
|
True if all tests passed, False otherwise
|
|
315
316
|
"""
|
|
317
|
+
service, endpoints = target.service, target.endpoints
|
|
316
318
|
self.console.print(f"[cyan]🧪 Quick testing {service} ({len(endpoints)} endpoints)[/cyan]")
|
|
317
319
|
|
|
318
320
|
if not endpoints:
|
|
@@ -346,17 +348,17 @@ class WupWatcher:
|
|
|
346
348
|
|
|
347
349
|
return passed
|
|
348
350
|
|
|
349
|
-
async def run_detail_test(self,
|
|
351
|
+
async def run_detail_test(self, target: ServiceTestTarget) -> Dict:
|
|
350
352
|
"""
|
|
351
353
|
Run a detailed test for a service with blame report.
|
|
352
354
|
|
|
353
355
|
Args:
|
|
354
|
-
|
|
355
|
-
endpoints: List of endpoints to test
|
|
356
|
+
target: Service and endpoints to test
|
|
356
357
|
|
|
357
358
|
Returns:
|
|
358
359
|
Dictionary with test results and blame information
|
|
359
360
|
"""
|
|
361
|
+
service, endpoints = target.service, target.endpoints
|
|
360
362
|
self.console.print(f"[cyan]🔍 Detail testing {service} ({len(endpoints)} endpoints)[/cyan]")
|
|
361
363
|
|
|
362
364
|
results = {
|
|
@@ -564,35 +566,42 @@ class WupWatcher:
|
|
|
564
566
|
return observer
|
|
565
567
|
raise
|
|
566
568
|
|
|
567
|
-
def start_watching(self, watch_paths: Optional[List[str]] = None):
|
|
569
|
+
def start_watching(self, watch_paths: Optional[List[str]] = None) -> bool:
|
|
568
570
|
"""
|
|
569
571
|
Start watching for file changes.
|
|
570
|
-
|
|
572
|
+
|
|
571
573
|
Args:
|
|
572
574
|
watch_paths: List of paths to watch (default: from config or common source directories)
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
False when no valid paths could be watched (configuration error —
|
|
578
|
+
CLI turns this into a non-zero exit so supervisors notice instead
|
|
579
|
+
of treating a watcher that never watched as a clean run),
|
|
580
|
+
True after a graceful interrupt.
|
|
573
581
|
"""
|
|
574
582
|
if watch_paths is None:
|
|
575
583
|
watch_paths = self.build_watched_paths()
|
|
576
|
-
|
|
584
|
+
|
|
577
585
|
# Filter to existing paths
|
|
578
586
|
watch_paths = [p for p in watch_paths if Path(p).exists()]
|
|
579
|
-
|
|
587
|
+
|
|
580
588
|
if not watch_paths:
|
|
581
589
|
self.console.print("[red]No valid paths to watch[/red]")
|
|
582
|
-
return
|
|
583
|
-
|
|
590
|
+
return False
|
|
591
|
+
|
|
584
592
|
event_handler = WupEventHandler(self)
|
|
585
593
|
observer = self._create_and_start_observer(event_handler, watch_paths)
|
|
586
594
|
self.console.print(f"[green]🕵️ Watching: {', '.join(watch_paths)}[/green]")
|
|
587
|
-
|
|
595
|
+
|
|
588
596
|
try:
|
|
589
597
|
while True:
|
|
590
598
|
asyncio.run(self.process_test_queue_once())
|
|
591
599
|
time.sleep(1)
|
|
592
600
|
except KeyboardInterrupt:
|
|
593
601
|
observer.stop()
|
|
594
|
-
|
|
602
|
+
|
|
595
603
|
observer.join()
|
|
604
|
+
return True
|
|
596
605
|
|
|
597
606
|
def create_status_table(self) -> Table:
|
|
598
607
|
"""
|
|
@@ -15,6 +15,7 @@ from .config import (
|
|
|
15
15
|
WebConfig,
|
|
16
16
|
WupConfig,
|
|
17
17
|
)
|
|
18
|
+
from .target import ServiceTestTarget
|
|
18
19
|
|
|
19
20
|
ServiceType = str # Type alias: 'web', 'shell', 'auto'
|
|
20
21
|
|
|
@@ -25,6 +26,7 @@ __all__ = [
|
|
|
25
26
|
"ServiceConfig",
|
|
26
27
|
"ServiceTestConfig",
|
|
27
28
|
"ServiceType",
|
|
29
|
+
"ServiceTestTarget",
|
|
28
30
|
"TestQLConfig",
|
|
29
31
|
"TestStrategyConfig",
|
|
30
32
|
"VisualDiffConfig",
|
|
@@ -97,6 +97,9 @@ class VisualDiffConfig:
|
|
|
97
97
|
threshold_changed: int = 5 # min changed attrs to report
|
|
98
98
|
min_text_length: int = 200 # anomaly if rendered text is too short
|
|
99
99
|
min_dom_nodes: int = 20 # anomaly if DOM is suspiciously tiny
|
|
100
|
+
page_settle_ms: int = 750 # wait after networkidle before snapshotting SPA DOM
|
|
101
|
+
issue_retry_count: int = 0 # retry transient Vite/HMR/page-shell issues
|
|
102
|
+
issue_retry_delay_seconds: float = 2.0
|
|
100
103
|
error_selectors: List[str] = field(default_factory=lambda: [
|
|
101
104
|
"#error-container",
|
|
102
105
|
".error-container",
|