wup 0.2.68__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.68/wup.egg-info → wup-0.2.69}/PKG-INFO +7 -7
- {wup-0.2.68 → wup-0.2.69}/README.md +6 -6
- {wup-0.2.68 → wup-0.2.69}/pyproject.toml +1 -1
- wup-0.2.69/tests/test_planfile_reporter_dedupe.py +78 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_testql_watcher.py +13 -4
- wup-0.2.69/tests/test_watch_no_paths_exit.py +30 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_wup.py +17 -8
- {wup-0.2.68 → wup-0.2.69}/wup/__init__.py +1 -1
- {wup-0.2.68 → wup-0.2.69}/wup/cli.py +2 -1
- {wup-0.2.68 → wup-0.2.69}/wup/core.py +25 -16
- {wup-0.2.68 → wup-0.2.69}/wup/models/__init__.py +2 -0
- wup-0.2.69/wup/models/target.py +23 -0
- {wup-0.2.68 → wup-0.2.69}/wup/planfile_reporter.py +36 -3
- {wup-0.2.68 → wup-0.2.69}/wup/testql_watcher.py +9 -4
- {wup-0.2.68 → wup-0.2.69}/wup/visual_diff.py +8 -4
- {wup-0.2.68 → wup-0.2.69/wup.egg-info}/PKG-INFO +7 -7
- {wup-0.2.68 → wup-0.2.69}/wup.egg-info/SOURCES.txt +3 -0
- {wup-0.2.68 → wup-0.2.69}/LICENSE +0 -0
- {wup-0.2.68 → wup-0.2.69}/setup.cfg +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_assistant.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_auto_detection.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_cli_bridge.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_cli_filtering.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_control.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_e2e.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_endpoints_init_cli.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_health_summary_passed.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_monitoring_manifest.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_probe_mutex.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_service_inference.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_status_data.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_sync.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_testql_monitor.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_visual_diff_periodic_skip.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_visual_diff_progress.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_watch_exclude.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_web_client.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/tests/test_wup_generate.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/_ast_detector.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/_base_detector.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/_hash_detector.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/_yaml_detector.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/anomaly_detector.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/anomaly_models.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/assistant.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/assistant_discovery.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/assistant_validator.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/bus.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/cli_bridge.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/cli_config_generator.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/cli_scanner.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/config.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/control.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/dependency_mapper.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/endpoints.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/event_store.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/file_watcher/events/file_events.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/generate.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/init_cli.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/models/config.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/monitoring_manifest.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/paths.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/status_data.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/sync.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/testing/events/health_events.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/testing/events/test_results.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/testing/handlers/event_handlers.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/testing/handlers/health_handlers.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/testing/queries/health_queries.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/testql_cli_generator.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/testql_discovery.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/testql_monitor.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/validate.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup/web_client.py +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.68 → wup-0.2.69}/wup.egg-info/requires.txt +0 -0
- {wup-0.2.68 → 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,17 +42,17 @@ Dynamic: license-file
|
|
|
42
42
|
|
|
43
43
|
## AI Cost Tracking
|
|
44
44
|
|
|
45
|
-
    
|
|
46
|
+
  
|
|
47
47
|
|
|
48
|
-
- 🤖 **LLM usage:** $
|
|
49
|
-
- 👤 **Human dev:** ~$
|
|
48
|
+
- 🤖 **LLM usage:** $5.8610 (97 commits)
|
|
49
|
+
- 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
|
|
50
50
|
|
|
51
|
-
Generated on 2026-07-
|
|
51
|
+
Generated on 2026-07-16 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
52
52
|
|
|
53
53
|
---
|
|
54
54
|
|
|
55
|
-
    
|
|
56
56
|
|
|
57
57
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
58
58
|
|
|
@@ -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:** $5.8610 (97 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
|
-
Generated on 2026-07-
|
|
12
|
+
Generated on 2026-07-16 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
|
|
|
@@ -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]
|
|
@@ -442,7 +445,13 @@ def test_visual_differ_disabled_by_default():
|
|
|
442
445
|
# Differ is created but flagged disabled — run_for_service() must be a no-op
|
|
443
446
|
assert watcher.visual_differ is not None
|
|
444
447
|
assert watcher.visual_differ.cfg.enabled is False
|
|
445
|
-
|
|
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
|
+
)
|
|
446
455
|
assert results == []
|
|
447
456
|
|
|
448
457
|
|
|
@@ -532,8 +541,8 @@ def test_quick_pass_actions_prefer_config_endpoints_for_visual_diff():
|
|
|
532
541
|
self.cfg = VisualDiffConfig(enabled=True)
|
|
533
542
|
self.calls = []
|
|
534
543
|
|
|
535
|
-
async def run_for_service(self,
|
|
536
|
-
self.calls.append((service, list(endpoints)))
|
|
544
|
+
async def run_for_service(self, target):
|
|
545
|
+
self.calls.append((target.service, list(target.endpoints)))
|
|
537
546
|
return []
|
|
538
547
|
|
|
539
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,
|
|
@@ -1056,7 +1057,7 @@ class TestVisualDiffer:
|
|
|
1056
1057
|
pages_from_endpoints=False,
|
|
1057
1058
|
)
|
|
1058
1059
|
differ = VisualDiffer(tmpdir, cfg)
|
|
1059
|
-
pages = differ._pages_for_service("users", ["/api/users"])
|
|
1060
|
+
pages = differ._pages_for_service(ServiceTestTarget(service="users", endpoints=["/api/users"]))
|
|
1060
1061
|
assert pages == ["http://localhost:8080/dashboard"]
|
|
1061
1062
|
|
|
1062
1063
|
def test_pages_for_service_from_endpoints(self):
|
|
@@ -1066,7 +1067,9 @@ class TestVisualDiffer:
|
|
|
1066
1067
|
pages_from_endpoints=True,
|
|
1067
1068
|
)
|
|
1068
1069
|
differ = VisualDiffer(tmpdir, cfg)
|
|
1069
|
-
pages = differ._pages_for_service(
|
|
1070
|
+
pages = differ._pages_for_service(
|
|
1071
|
+
ServiceTestTarget(service="users", endpoints=["/connect-users", "/connect-users/1"])
|
|
1072
|
+
)
|
|
1070
1073
|
assert "http://localhost:8080/connect-users" in pages
|
|
1071
1074
|
assert "http://localhost:8080/connect-users/1" in pages
|
|
1072
1075
|
|
|
@@ -1083,8 +1086,10 @@ class TestVisualDiffer:
|
|
|
1083
1086
|
)
|
|
1084
1087
|
differ = VisualDiffer(tmpdir, cfg)
|
|
1085
1088
|
pages = differ._pages_for_service(
|
|
1086
|
-
|
|
1087
|
-
|
|
1089
|
+
ServiceTestTarget(
|
|
1090
|
+
service="backend",
|
|
1091
|
+
endpoints=["/api/v3/health", "/connect-config", "http://localhost:8080/connect-data"],
|
|
1092
|
+
)
|
|
1088
1093
|
)
|
|
1089
1094
|
assert "http://localhost:8080/api/v3/health" not in pages
|
|
1090
1095
|
assert "http://localhost:8080/connect-config" in pages
|
|
@@ -1097,7 +1102,7 @@ class TestVisualDiffer:
|
|
|
1097
1102
|
pages_from_endpoints=False,
|
|
1098
1103
|
)
|
|
1099
1104
|
differ = VisualDiffer(tmpdir, cfg)
|
|
1100
|
-
pages = differ._pages_for_service("users"
|
|
1105
|
+
pages = differ._pages_for_service(ServiceTestTarget(service="users"))
|
|
1101
1106
|
assert pages == ["http://localhost:8080/users"]
|
|
1102
1107
|
|
|
1103
1108
|
def test_pages_for_service_absolute_url_passthrough(self):
|
|
@@ -1108,7 +1113,7 @@ class TestVisualDiffer:
|
|
|
1108
1113
|
pages_from_endpoints=False,
|
|
1109
1114
|
)
|
|
1110
1115
|
differ = VisualDiffer(tmpdir, cfg)
|
|
1111
|
-
pages = differ._pages_for_service("svc"
|
|
1116
|
+
pages = differ._pages_for_service(ServiceTestTarget(service="svc"))
|
|
1112
1117
|
assert pages == ["https://example.com/connect-dashboard"]
|
|
1113
1118
|
|
|
1114
1119
|
def test_diff_snapshots_baseline(self):
|
|
@@ -1144,7 +1149,9 @@ class TestVisualDiffer:
|
|
|
1144
1149
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
1145
1150
|
cfg = VisualDiffConfig(enabled=False)
|
|
1146
1151
|
differ = VisualDiffer(tmpdir, cfg)
|
|
1147
|
-
results = asyncio.run(
|
|
1152
|
+
results = asyncio.run(
|
|
1153
|
+
differ.run_for_service(ServiceTestTarget(service="svc", endpoints=["/x"]))
|
|
1154
|
+
)
|
|
1148
1155
|
assert results == []
|
|
1149
1156
|
|
|
1150
1157
|
def test_run_for_service_summarizes_fetch_errors(self, monkeypatch):
|
|
@@ -1177,7 +1184,9 @@ class TestVisualDiffer:
|
|
|
1177
1184
|
monkeypatch.setattr(visual_diff_module.console, "print", lambda message: printed.append(str(message)))
|
|
1178
1185
|
|
|
1179
1186
|
results = asyncio.run(
|
|
1180
|
-
differ.run_for_service(
|
|
1187
|
+
differ.run_for_service(
|
|
1188
|
+
ServiceTestTarget(service="frontend", endpoints=["/a", "/b", "/c", "/d"])
|
|
1189
|
+
)
|
|
1181
1190
|
)
|
|
1182
1191
|
|
|
1183
1192
|
assert len(results) == 4
|
|
@@ -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):
|
|
@@ -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",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Runtime data models for WUP.
|
|
2
|
+
|
|
3
|
+
A :class:`ServiceTestTarget` bundles a service name with the endpoints to test
|
|
4
|
+
for it. It replaces the ``(service, endpoints)`` pair that was previously
|
|
5
|
+
passed together through every test-entry method on ``WupWatcher``,
|
|
6
|
+
``TestQLWatcher`` and ``VisualDiffer`` (a recurring data clump).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import List
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ServiceTestTarget:
|
|
15
|
+
"""A service and the endpoints that should be exercised for it.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
service: Name of the service under test.
|
|
19
|
+
endpoints: Endpoint URLs (or page routes) to probe for the service.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
service: str
|
|
23
|
+
endpoints: List[str] = field(default_factory=list)
|
|
@@ -46,7 +46,14 @@ class PlanfileReporter:
|
|
|
46
46
|
dedupe = self._load_dedupe()
|
|
47
47
|
existing = dedupe.get(fingerprint)
|
|
48
48
|
if existing:
|
|
49
|
-
|
|
49
|
+
# A registry entry only mutes the recurrence while its ticket is
|
|
50
|
+
# still open — a regression after the ticket was closed (done or
|
|
51
|
+
# canceled) must file a fresh ticket, otherwise the failure is
|
|
52
|
+
# silently invisible forever (stale entries survive watcher
|
|
53
|
+
# restarts, so clear_service_stage alone does not cover this).
|
|
54
|
+
existing_id = existing.get("ticket_id")
|
|
55
|
+
if existing_id and not self._ticket_is_closed(existing_id):
|
|
56
|
+
return existing_id
|
|
50
57
|
|
|
51
58
|
name = self._ticket_name(service=service, stage=stage, status=status)
|
|
52
59
|
description = self._ticket_description(
|
|
@@ -73,6 +80,32 @@ class PlanfileReporter:
|
|
|
73
80
|
self.console.print(f"[yellow]🧾 WUP created planfile ticket {ticket_id}: {name}[/yellow]")
|
|
74
81
|
return ticket_id
|
|
75
82
|
|
|
83
|
+
def _ticket_is_closed(self, ticket_id: str) -> bool:
|
|
84
|
+
"""True when the deduped ticket is done/canceled (regression may re-file).
|
|
85
|
+
|
|
86
|
+
Conservative on any error (planfile missing, ticket gone, bad JSON):
|
|
87
|
+
treat the ticket as still open so dedupe keeps muting — a wrong True
|
|
88
|
+
would spam duplicate tickets, a wrong False only delays a re-file.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
result = subprocess.run(
|
|
92
|
+
[self.config.command, "ticket", "show", ticket_id, "--format", "json"],
|
|
93
|
+
cwd=str(self.project_root),
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
timeout=15,
|
|
97
|
+
)
|
|
98
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
99
|
+
return False
|
|
100
|
+
if result.returncode != 0:
|
|
101
|
+
return False
|
|
102
|
+
try:
|
|
103
|
+
payload = json.loads(result.stdout or "{}")
|
|
104
|
+
except ValueError:
|
|
105
|
+
return False
|
|
106
|
+
ticket = payload.get("ticket", payload) if isinstance(payload, dict) else {}
|
|
107
|
+
return str(ticket.get("status", "")).strip().lower() in {"done", "canceled", "cancelled"}
|
|
108
|
+
|
|
76
109
|
def clear_service_stage(self, *, service: str, stage: str) -> None:
|
|
77
110
|
"""Allow a future recurrence to create a fresh ticket after recovery."""
|
|
78
111
|
if not self.enabled or not self.dedupe_path.exists():
|
|
@@ -118,7 +151,7 @@ class PlanfileReporter:
|
|
|
118
151
|
timeout=30,
|
|
119
152
|
)
|
|
120
153
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
121
|
-
self.console.print(f"[yellow]planfile ticket creation skipped: {exc}[/yellow]")
|
|
154
|
+
self.console.print(f"[yellow]planfile ticket creation skipped: {exc} (cmd: {cmd[0]})[/yellow]")
|
|
122
155
|
return None
|
|
123
156
|
|
|
124
157
|
stdout = (result.stdout or "").strip()
|
|
@@ -134,7 +167,7 @@ class PlanfileReporter:
|
|
|
134
167
|
timeout=30,
|
|
135
168
|
)
|
|
136
169
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
137
|
-
self.console.print(f"[yellow]planfile ticket creation skipped: {exc}[/yellow]")
|
|
170
|
+
self.console.print(f"[yellow]planfile ticket creation skipped: {exc} (cmd: {cmd[0]})[/yellow]")
|
|
138
171
|
return None
|
|
139
172
|
stdout = (result.stdout or "").strip()
|
|
140
173
|
stderr = (result.stderr or "").strip()
|
|
@@ -15,6 +15,7 @@ from urllib import error, request
|
|
|
15
15
|
|
|
16
16
|
from .config import load_config
|
|
17
17
|
from .core import WupWatcher
|
|
18
|
+
from .models.target import ServiceTestTarget
|
|
18
19
|
from wup.bus import bus
|
|
19
20
|
from wup.testing.events.test_results import ScenarioFailed, ScenarioPassed
|
|
20
21
|
from .models.config import WupConfig, ServiceConfig
|
|
@@ -590,7 +591,9 @@ class TestQLWatcher(WupWatcher):
|
|
|
590
591
|
self.console.print(f"[green]✓ Quick TestQL passed for {service}[/green]")
|
|
591
592
|
if self._should_run_visual_diff():
|
|
592
593
|
visual_endpoints = self._get_config_endpoints_for_service(service) or merged_endpoints
|
|
593
|
-
visual_results = await self.visual_differ.run_for_service(
|
|
594
|
+
visual_results = await self.visual_differ.run_for_service(
|
|
595
|
+
ServiceTestTarget(service=service, endpoints=visual_endpoints)
|
|
596
|
+
)
|
|
594
597
|
visual_issues = [
|
|
595
598
|
item for item in visual_results
|
|
596
599
|
if item.get("diff", {}).get("status") == "issue"
|
|
@@ -781,7 +784,9 @@ class TestQLWatcher(WupWatcher):
|
|
|
781
784
|
await self.web_client.send_pass(service=service, stage="quick")
|
|
782
785
|
if self.visual_differ and self.visual_differ.cfg.enabled:
|
|
783
786
|
visual_endpoints = self._get_config_endpoints_for_service(service) or merged_endpoints
|
|
784
|
-
visual_results = await self.visual_differ.run_for_service(
|
|
787
|
+
visual_results = await self.visual_differ.run_for_service(
|
|
788
|
+
ServiceTestTarget(service=service, endpoints=visual_endpoints)
|
|
789
|
+
)
|
|
785
790
|
visual_issues = [
|
|
786
791
|
item for item in visual_results
|
|
787
792
|
if item.get("diff", {}).get("status") == "issue"
|
|
@@ -1007,7 +1012,7 @@ class TestQLWatcher(WupWatcher):
|
|
|
1007
1012
|
self._probe_thread.start()
|
|
1008
1013
|
self.console.print(f"[green]Live probes enabled (every {interval}s)[/green]")
|
|
1009
1014
|
|
|
1010
|
-
def start_watching(self, watch_paths: Optional[List[str]] = None):
|
|
1015
|
+
def start_watching(self, watch_paths: Optional[List[str]] = None) -> bool:
|
|
1011
1016
|
"""Start file watcher and optional periodic TestQL/HTTP probes."""
|
|
1012
1017
|
self._start_periodic_probe_thread()
|
|
1013
|
-
super().start_watching(watch_paths)
|
|
1018
|
+
return super().start_watching(watch_paths)
|
|
@@ -35,6 +35,7 @@ from rich.progress import (
|
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
from .models.config import VisualDiffConfig
|
|
38
|
+
from .models.target import ServiceTestTarget
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
console = Console()
|
|
@@ -346,7 +347,8 @@ class VisualDiffer:
|
|
|
346
347
|
Usage::
|
|
347
348
|
|
|
348
349
|
differ = VisualDiffer(project_root, config.visual_diff)
|
|
349
|
-
|
|
350
|
+
target = ServiceTestTarget(service, changed_endpoints)
|
|
351
|
+
results = await differ.run_for_service(target)
|
|
350
352
|
"""
|
|
351
353
|
|
|
352
354
|
def __init__(
|
|
@@ -363,8 +365,9 @@ class VisualDiffer:
|
|
|
363
365
|
self.base_url = _resolve_base_url(cfg)
|
|
364
366
|
self._console = console or globals()["console"]
|
|
365
367
|
|
|
366
|
-
def _pages_for_service(self,
|
|
368
|
+
def _pages_for_service(self, target: ServiceTestTarget) -> List[str]:
|
|
367
369
|
"""Build list of full URLs to scan for this service."""
|
|
370
|
+
service, endpoints = target.service, target.endpoints
|
|
368
371
|
pages: List[str] = list(self.cfg.pages)
|
|
369
372
|
|
|
370
373
|
if self.cfg.pages_from_endpoints and endpoints:
|
|
@@ -458,12 +461,13 @@ class VisualDiffer:
|
|
|
458
461
|
)
|
|
459
462
|
|
|
460
463
|
async def run_for_service(
|
|
461
|
-
self,
|
|
464
|
+
self, target: ServiceTestTarget
|
|
462
465
|
) -> List[Dict[str, Any]]:
|
|
463
466
|
"""
|
|
464
467
|
Scan pages for *service*, diff against stored snapshots.
|
|
465
468
|
Returns list of diff results (one per page).
|
|
466
469
|
"""
|
|
470
|
+
service = target.service
|
|
467
471
|
if not self.cfg.enabled:
|
|
468
472
|
return []
|
|
469
473
|
|
|
@@ -474,7 +478,7 @@ class VisualDiffer:
|
|
|
474
478
|
if self.cfg.delay_seconds > 0:
|
|
475
479
|
await asyncio.sleep(self.cfg.delay_seconds)
|
|
476
480
|
|
|
477
|
-
pages = self._pages_for_service(
|
|
481
|
+
pages = self._pages_for_service(target)
|
|
478
482
|
max_pages = max(1, int(self.cfg.max_pages or 5))
|
|
479
483
|
if len(pages) > max_pages:
|
|
480
484
|
pages = pages[:max_pages]
|
|
@@ -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,17 +42,17 @@ Dynamic: license-file
|
|
|
42
42
|
|
|
43
43
|
## AI Cost Tracking
|
|
44
44
|
|
|
45
|
-
    
|
|
46
|
+
  
|
|
47
47
|
|
|
48
|
-
- 🤖 **LLM usage:** $
|
|
49
|
-
- 👤 **Human dev:** ~$
|
|
48
|
+
- 🤖 **LLM usage:** $5.8610 (97 commits)
|
|
49
|
+
- 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
|
|
50
50
|
|
|
51
|
-
Generated on 2026-07-
|
|
51
|
+
Generated on 2026-07-16 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
52
52
|
|
|
53
53
|
---
|
|
54
54
|
|
|
55
|
-
    
|
|
56
56
|
|
|
57
57
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
58
58
|
|
|
@@ -10,6 +10,7 @@ tests/test_e2e.py
|
|
|
10
10
|
tests/test_endpoints_init_cli.py
|
|
11
11
|
tests/test_health_summary_passed.py
|
|
12
12
|
tests/test_monitoring_manifest.py
|
|
13
|
+
tests/test_planfile_reporter_dedupe.py
|
|
13
14
|
tests/test_probe_mutex.py
|
|
14
15
|
tests/test_service_inference.py
|
|
15
16
|
tests/test_status_data.py
|
|
@@ -19,6 +20,7 @@ tests/test_testql_watcher.py
|
|
|
19
20
|
tests/test_visual_diff_periodic_skip.py
|
|
20
21
|
tests/test_visual_diff_progress.py
|
|
21
22
|
tests/test_watch_exclude.py
|
|
23
|
+
tests/test_watch_no_paths_exit.py
|
|
22
24
|
tests/test_web_client.py
|
|
23
25
|
tests/test_wup.py
|
|
24
26
|
tests/test_wup_generate.py
|
|
@@ -66,6 +68,7 @@ wup.egg-info/top_level.txt
|
|
|
66
68
|
wup/file_watcher/events/file_events.py
|
|
67
69
|
wup/models/__init__.py
|
|
68
70
|
wup/models/config.py
|
|
71
|
+
wup/models/target.py
|
|
69
72
|
wup/testing/events/health_events.py
|
|
70
73
|
wup/testing/events/test_results.py
|
|
71
74
|
wup/testing/handlers/event_handlers.py
|
|
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
|
|
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
|
|
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
|