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.
Files changed (78) hide show
  1. {wup-0.2.67/wup.egg-info → wup-0.2.69}/PKG-INFO +7 -5
  2. {wup-0.2.67 → wup-0.2.69}/README.md +6 -4
  3. {wup-0.2.67 → wup-0.2.69}/pyproject.toml +1 -1
  4. wup-0.2.69/tests/test_planfile_reporter_dedupe.py +78 -0
  5. {wup-0.2.67 → wup-0.2.69}/tests/test_testql_watcher.py +41 -4
  6. wup-0.2.69/tests/test_watch_no_paths_exit.py +30 -0
  7. {wup-0.2.67 → wup-0.2.69}/tests/test_wup.py +98 -8
  8. {wup-0.2.67 → wup-0.2.69}/wup/__init__.py +1 -1
  9. {wup-0.2.67 → wup-0.2.69}/wup/cli.py +2 -1
  10. {wup-0.2.67 → wup-0.2.69}/wup/config.py +6 -0
  11. {wup-0.2.67 → wup-0.2.69}/wup/core.py +25 -16
  12. {wup-0.2.67 → wup-0.2.69}/wup/models/__init__.py +2 -0
  13. {wup-0.2.67 → wup-0.2.69}/wup/models/config.py +3 -0
  14. wup-0.2.69/wup/models/target.py +23 -0
  15. {wup-0.2.67 → wup-0.2.69}/wup/planfile_reporter.py +55 -2
  16. {wup-0.2.67 → wup-0.2.69}/wup/testql_watcher.py +20 -9
  17. {wup-0.2.67 → wup-0.2.69}/wup/visual_diff.py +62 -5
  18. {wup-0.2.67 → wup-0.2.69/wup.egg-info}/PKG-INFO +7 -5
  19. {wup-0.2.67 → wup-0.2.69}/wup.egg-info/SOURCES.txt +3 -0
  20. {wup-0.2.67 → wup-0.2.69}/LICENSE +0 -0
  21. {wup-0.2.67 → wup-0.2.69}/setup.cfg +0 -0
  22. {wup-0.2.67 → wup-0.2.69}/tests/test_assistant.py +0 -0
  23. {wup-0.2.67 → wup-0.2.69}/tests/test_auto_detection.py +0 -0
  24. {wup-0.2.67 → wup-0.2.69}/tests/test_cli_bridge.py +0 -0
  25. {wup-0.2.67 → wup-0.2.69}/tests/test_cli_filtering.py +0 -0
  26. {wup-0.2.67 → wup-0.2.69}/tests/test_control.py +0 -0
  27. {wup-0.2.67 → wup-0.2.69}/tests/test_e2e.py +0 -0
  28. {wup-0.2.67 → wup-0.2.69}/tests/test_endpoints_init_cli.py +0 -0
  29. {wup-0.2.67 → wup-0.2.69}/tests/test_health_summary_passed.py +0 -0
  30. {wup-0.2.67 → wup-0.2.69}/tests/test_monitoring_manifest.py +0 -0
  31. {wup-0.2.67 → wup-0.2.69}/tests/test_probe_mutex.py +0 -0
  32. {wup-0.2.67 → wup-0.2.69}/tests/test_service_inference.py +0 -0
  33. {wup-0.2.67 → wup-0.2.69}/tests/test_status_data.py +0 -0
  34. {wup-0.2.67 → wup-0.2.69}/tests/test_sync.py +0 -0
  35. {wup-0.2.67 → wup-0.2.69}/tests/test_testql_monitor.py +0 -0
  36. {wup-0.2.67 → wup-0.2.69}/tests/test_visual_diff_periodic_skip.py +0 -0
  37. {wup-0.2.67 → wup-0.2.69}/tests/test_visual_diff_progress.py +0 -0
  38. {wup-0.2.67 → wup-0.2.69}/tests/test_watch_exclude.py +0 -0
  39. {wup-0.2.67 → wup-0.2.69}/tests/test_web_client.py +0 -0
  40. {wup-0.2.67 → wup-0.2.69}/tests/test_wup_generate.py +0 -0
  41. {wup-0.2.67 → wup-0.2.69}/wup/_ast_detector.py +0 -0
  42. {wup-0.2.67 → wup-0.2.69}/wup/_base_detector.py +0 -0
  43. {wup-0.2.67 → wup-0.2.69}/wup/_hash_detector.py +0 -0
  44. {wup-0.2.67 → wup-0.2.69}/wup/_yaml_detector.py +0 -0
  45. {wup-0.2.67 → wup-0.2.69}/wup/anomaly_detector.py +0 -0
  46. {wup-0.2.67 → wup-0.2.69}/wup/anomaly_models.py +0 -0
  47. {wup-0.2.67 → wup-0.2.69}/wup/assistant.py +0 -0
  48. {wup-0.2.67 → wup-0.2.69}/wup/assistant_discovery.py +0 -0
  49. {wup-0.2.67 → wup-0.2.69}/wup/assistant_validator.py +0 -0
  50. {wup-0.2.67 → wup-0.2.69}/wup/bus.py +0 -0
  51. {wup-0.2.67 → wup-0.2.69}/wup/cli_bridge.py +0 -0
  52. {wup-0.2.67 → wup-0.2.69}/wup/cli_config_generator.py +0 -0
  53. {wup-0.2.67 → wup-0.2.69}/wup/cli_scanner.py +0 -0
  54. {wup-0.2.67 → wup-0.2.69}/wup/control.py +0 -0
  55. {wup-0.2.67 → wup-0.2.69}/wup/dependency_mapper.py +0 -0
  56. {wup-0.2.67 → wup-0.2.69}/wup/endpoints.py +0 -0
  57. {wup-0.2.67 → wup-0.2.69}/wup/event_store.py +0 -0
  58. {wup-0.2.67 → wup-0.2.69}/wup/file_watcher/events/file_events.py +0 -0
  59. {wup-0.2.67 → wup-0.2.69}/wup/generate.py +0 -0
  60. {wup-0.2.67 → wup-0.2.69}/wup/init_cli.py +0 -0
  61. {wup-0.2.67 → wup-0.2.69}/wup/monitoring_manifest.py +0 -0
  62. {wup-0.2.67 → wup-0.2.69}/wup/paths.py +0 -0
  63. {wup-0.2.67 → wup-0.2.69}/wup/status_data.py +0 -0
  64. {wup-0.2.67 → wup-0.2.69}/wup/sync.py +0 -0
  65. {wup-0.2.67 → wup-0.2.69}/wup/testing/events/health_events.py +0 -0
  66. {wup-0.2.67 → wup-0.2.69}/wup/testing/events/test_results.py +0 -0
  67. {wup-0.2.67 → wup-0.2.69}/wup/testing/handlers/event_handlers.py +0 -0
  68. {wup-0.2.67 → wup-0.2.69}/wup/testing/handlers/health_handlers.py +0 -0
  69. {wup-0.2.67 → wup-0.2.69}/wup/testing/queries/health_queries.py +0 -0
  70. {wup-0.2.67 → wup-0.2.69}/wup/testql_cli_generator.py +0 -0
  71. {wup-0.2.67 → wup-0.2.69}/wup/testql_discovery.py +0 -0
  72. {wup-0.2.67 → wup-0.2.69}/wup/testql_monitor.py +0 -0
  73. {wup-0.2.67 → wup-0.2.69}/wup/validate.py +0 -0
  74. {wup-0.2.67 → wup-0.2.69}/wup/web_client.py +0 -0
  75. {wup-0.2.67 → wup-0.2.69}/wup.egg-info/dependency_links.txt +0 -0
  76. {wup-0.2.67 → wup-0.2.69}/wup.egg-info/entry_points.txt +0 -0
  77. {wup-0.2.67 → wup-0.2.69}/wup.egg-info/requires.txt +0 -0
  78. {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.67
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
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-yellow) ![AI Model](https://img.shields.io/badge/AI%20Model-openrouter%2Fqwen%2Fqwen3-coder-next-lightgrey)
45
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.69-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
46
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$5.86-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-51.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
46
47
 
47
- This project uses AI-generated code. Total cost: **$7.5000** with **80** AI commits.
48
+ - 🤖 **LLM usage:** $5.8610 (97 commits)
49
+ - 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
48
50
 
49
- Generated on 2026-06-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/models/openrouter/qwen/qwen3-coder-next)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.67-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
55
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.69-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
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
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-yellow) ![AI Model](https://img.shields.io/badge/AI%20Model-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.69-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-$5.86-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-51.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
7
8
 
8
- This project uses AI-generated code. Total cost: **$7.5000** with **80** AI commits.
9
+ - 🤖 **LLM usage:** $5.8610 (97 commits)
10
+ - 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
9
11
 
10
- Generated on 2026-06-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/models/openrouter/qwen/qwen3-coder-next)
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
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.67-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.69-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
15
17
 
16
18
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
17
19
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wup"
7
- version = "0.2.67"
7
+ version = "0.2.69"
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.10"
@@ -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
- assert len(calls) == 1
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
- results = asyncio.run(watcher.visual_differ.run_for_service("svc", ["/x"]))
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, service, endpoints):
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("users", ["/connect-users", "/connect-users/1"])
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
- "backend",
1069
- ["/api/v3/health", "/connect-config", "http://localhost:8080/connect-data"],
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(differ.run_for_service("svc", ["/x"]))
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("frontend", ["/a", "/b", "/c", "/d"])
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.67"
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(service, endpoints)
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(service, endpoints)
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, service: str, endpoints: List[str]) -> bool:
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
- service: Service name
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, service: str, endpoints: List[str]) -> Dict:
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
- service: Service name
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",