wup 0.2.68__tar.gz → 0.2.70__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 (80) hide show
  1. {wup-0.2.68/wup.egg-info → wup-0.2.70}/PKG-INFO +31 -7
  2. {wup-0.2.68 → wup-0.2.70}/README.md +30 -6
  3. {wup-0.2.68 → wup-0.2.70}/pyproject.toml +1 -1
  4. wup-0.2.70/tests/test_multi_project.py +117 -0
  5. wup-0.2.70/tests/test_planfile_reporter_dedupe.py +78 -0
  6. {wup-0.2.68 → wup-0.2.70}/tests/test_testql_watcher.py +13 -4
  7. wup-0.2.70/tests/test_watch_no_paths_exit.py +30 -0
  8. {wup-0.2.68 → wup-0.2.70}/tests/test_wup.py +17 -8
  9. {wup-0.2.68 → wup-0.2.70}/wup/__init__.py +1 -1
  10. {wup-0.2.68 → wup-0.2.70}/wup/cli.py +175 -41
  11. {wup-0.2.68 → wup-0.2.70}/wup/config.py +24 -4
  12. {wup-0.2.68 → wup-0.2.70}/wup/core.py +71 -21
  13. {wup-0.2.68 → wup-0.2.70}/wup/models/__init__.py +2 -0
  14. wup-0.2.70/wup/models/target.py +23 -0
  15. {wup-0.2.68 → wup-0.2.70}/wup/monitoring_manifest.py +44 -36
  16. wup-0.2.70/wup/multi.py +81 -0
  17. {wup-0.2.68 → wup-0.2.70}/wup/planfile_reporter.py +68 -24
  18. {wup-0.2.68 → wup-0.2.70}/wup/status_data.py +27 -16
  19. {wup-0.2.68 → wup-0.2.70}/wup/testql_watcher.py +35 -35
  20. {wup-0.2.68 → wup-0.2.70}/wup/visual_diff.py +8 -4
  21. {wup-0.2.68 → wup-0.2.70/wup.egg-info}/PKG-INFO +31 -7
  22. {wup-0.2.68 → wup-0.2.70}/wup.egg-info/SOURCES.txt +5 -0
  23. {wup-0.2.68 → wup-0.2.70}/LICENSE +0 -0
  24. {wup-0.2.68 → wup-0.2.70}/setup.cfg +0 -0
  25. {wup-0.2.68 → wup-0.2.70}/tests/test_assistant.py +0 -0
  26. {wup-0.2.68 → wup-0.2.70}/tests/test_auto_detection.py +0 -0
  27. {wup-0.2.68 → wup-0.2.70}/tests/test_cli_bridge.py +0 -0
  28. {wup-0.2.68 → wup-0.2.70}/tests/test_cli_filtering.py +0 -0
  29. {wup-0.2.68 → wup-0.2.70}/tests/test_control.py +0 -0
  30. {wup-0.2.68 → wup-0.2.70}/tests/test_e2e.py +0 -0
  31. {wup-0.2.68 → wup-0.2.70}/tests/test_endpoints_init_cli.py +0 -0
  32. {wup-0.2.68 → wup-0.2.70}/tests/test_health_summary_passed.py +0 -0
  33. {wup-0.2.68 → wup-0.2.70}/tests/test_monitoring_manifest.py +0 -0
  34. {wup-0.2.68 → wup-0.2.70}/tests/test_probe_mutex.py +0 -0
  35. {wup-0.2.68 → wup-0.2.70}/tests/test_service_inference.py +0 -0
  36. {wup-0.2.68 → wup-0.2.70}/tests/test_status_data.py +0 -0
  37. {wup-0.2.68 → wup-0.2.70}/tests/test_sync.py +0 -0
  38. {wup-0.2.68 → wup-0.2.70}/tests/test_testql_monitor.py +0 -0
  39. {wup-0.2.68 → wup-0.2.70}/tests/test_visual_diff_periodic_skip.py +0 -0
  40. {wup-0.2.68 → wup-0.2.70}/tests/test_visual_diff_progress.py +0 -0
  41. {wup-0.2.68 → wup-0.2.70}/tests/test_watch_exclude.py +0 -0
  42. {wup-0.2.68 → wup-0.2.70}/tests/test_web_client.py +0 -0
  43. {wup-0.2.68 → wup-0.2.70}/tests/test_wup_generate.py +0 -0
  44. {wup-0.2.68 → wup-0.2.70}/wup/_ast_detector.py +0 -0
  45. {wup-0.2.68 → wup-0.2.70}/wup/_base_detector.py +0 -0
  46. {wup-0.2.68 → wup-0.2.70}/wup/_hash_detector.py +0 -0
  47. {wup-0.2.68 → wup-0.2.70}/wup/_yaml_detector.py +0 -0
  48. {wup-0.2.68 → wup-0.2.70}/wup/anomaly_detector.py +0 -0
  49. {wup-0.2.68 → wup-0.2.70}/wup/anomaly_models.py +0 -0
  50. {wup-0.2.68 → wup-0.2.70}/wup/assistant.py +0 -0
  51. {wup-0.2.68 → wup-0.2.70}/wup/assistant_discovery.py +0 -0
  52. {wup-0.2.68 → wup-0.2.70}/wup/assistant_validator.py +0 -0
  53. {wup-0.2.68 → wup-0.2.70}/wup/bus.py +0 -0
  54. {wup-0.2.68 → wup-0.2.70}/wup/cli_bridge.py +0 -0
  55. {wup-0.2.68 → wup-0.2.70}/wup/cli_config_generator.py +0 -0
  56. {wup-0.2.68 → wup-0.2.70}/wup/cli_scanner.py +0 -0
  57. {wup-0.2.68 → wup-0.2.70}/wup/control.py +0 -0
  58. {wup-0.2.68 → wup-0.2.70}/wup/dependency_mapper.py +0 -0
  59. {wup-0.2.68 → wup-0.2.70}/wup/endpoints.py +0 -0
  60. {wup-0.2.68 → wup-0.2.70}/wup/event_store.py +0 -0
  61. {wup-0.2.68 → wup-0.2.70}/wup/file_watcher/events/file_events.py +0 -0
  62. {wup-0.2.68 → wup-0.2.70}/wup/generate.py +0 -0
  63. {wup-0.2.68 → wup-0.2.70}/wup/init_cli.py +0 -0
  64. {wup-0.2.68 → wup-0.2.70}/wup/models/config.py +0 -0
  65. {wup-0.2.68 → wup-0.2.70}/wup/paths.py +0 -0
  66. {wup-0.2.68 → wup-0.2.70}/wup/sync.py +0 -0
  67. {wup-0.2.68 → wup-0.2.70}/wup/testing/events/health_events.py +0 -0
  68. {wup-0.2.68 → wup-0.2.70}/wup/testing/events/test_results.py +0 -0
  69. {wup-0.2.68 → wup-0.2.70}/wup/testing/handlers/event_handlers.py +0 -0
  70. {wup-0.2.68 → wup-0.2.70}/wup/testing/handlers/health_handlers.py +0 -0
  71. {wup-0.2.68 → wup-0.2.70}/wup/testing/queries/health_queries.py +0 -0
  72. {wup-0.2.68 → wup-0.2.70}/wup/testql_cli_generator.py +0 -0
  73. {wup-0.2.68 → wup-0.2.70}/wup/testql_discovery.py +0 -0
  74. {wup-0.2.68 → wup-0.2.70}/wup/testql_monitor.py +0 -0
  75. {wup-0.2.68 → wup-0.2.70}/wup/validate.py +0 -0
  76. {wup-0.2.68 → wup-0.2.70}/wup/web_client.py +0 -0
  77. {wup-0.2.68 → wup-0.2.70}/wup.egg-info/dependency_links.txt +0 -0
  78. {wup-0.2.68 → wup-0.2.70}/wup.egg-info/entry_points.txt +0 -0
  79. {wup-0.2.68 → wup-0.2.70}/wup.egg-info/requires.txt +0 -0
  80. {wup-0.2.68 → wup-0.2.70}/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.68
3
+ Version: 0.2.70
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
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.68-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-$3.88-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-39.7h-blue) ![Model](https://img.shields.io/badge/Model-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.70-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-$6.52-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)
47
47
 
48
- - 🤖 **LLM usage:** $3.8816 (82 commits)
49
- - 👤 **Human dev:** ~$3968 (39.7h @ $100/h, 30min dedup)
48
+ - 🤖 **LLM usage:** $6.5152 (98 commits)
49
+ - 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
50
50
 
51
- Generated on 2026-07-03 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
51
+ Generated on 2026-07-17 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
52
52
 
53
53
  ---
54
54
 
55
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.68-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.70-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
56
56
 
57
57
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
58
58
 
@@ -147,6 +147,30 @@ wup watch ./my-project --config custom-config.yaml
147
147
  wup testql-endpoints /path/to/scenarios --output testql-deps.json
148
148
  ```
149
149
 
150
+ ### Watch Multiple Projects Simultaneously
151
+
152
+ Pass several project roots to watch them all at once in a single process. Each
153
+ project keeps its own `wup.yaml`, dependency map, file observer and test queue.
154
+
155
+ ```bash
156
+ # Watch several projects at the same time
157
+ wup watch ./service-a ./service-b ./service-c
158
+
159
+ # Discover: expand a monorepo root into every sub-project that has a wup.yaml
160
+ wup watch --discover ./my-monorepo
161
+
162
+ # Discover from the current directory
163
+ wup watch -D .
164
+ ```
165
+
166
+ `--discover` walks the immediate sub-directories of each given path and watches
167
+ those that already contain a `wup.yaml` (hidden and vendor folders such as
168
+ `node_modules`, `.venv`, `dist` and `build` are skipped). A relative `--deps`
169
+ path is resolved per project, so each project uses its own `deps.json`.
170
+
171
+ > The live `--dashboard` and single `--config` options apply to single-project
172
+ > runs only; when watching multiple projects each uses its own `wup.yaml`.
173
+
150
174
  ### Initialize Configuration
151
175
 
152
176
  ```bash
@@ -3,17 +3,17 @@
3
3
 
4
4
  ## AI Cost Tracking
5
5
 
6
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.68-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-$3.88-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-39.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.70-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-$6.52-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)
8
8
 
9
- - 🤖 **LLM usage:** $3.8816 (82 commits)
10
- - 👤 **Human dev:** ~$3968 (39.7h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $6.5152 (98 commits)
10
+ - 👤 **Human dev:** ~$5115 (51.1h @ $100/h, 30min dedup)
11
11
 
12
- Generated on 2026-07-03 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
12
+ Generated on 2026-07-17 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
13
13
 
14
14
  ---
15
15
 
16
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.68-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.70-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
17
17
 
18
18
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
19
19
 
@@ -108,6 +108,30 @@ wup watch ./my-project --config custom-config.yaml
108
108
  wup testql-endpoints /path/to/scenarios --output testql-deps.json
109
109
  ```
110
110
 
111
+ ### Watch Multiple Projects Simultaneously
112
+
113
+ Pass several project roots to watch them all at once in a single process. Each
114
+ project keeps its own `wup.yaml`, dependency map, file observer and test queue.
115
+
116
+ ```bash
117
+ # Watch several projects at the same time
118
+ wup watch ./service-a ./service-b ./service-c
119
+
120
+ # Discover: expand a monorepo root into every sub-project that has a wup.yaml
121
+ wup watch --discover ./my-monorepo
122
+
123
+ # Discover from the current directory
124
+ wup watch -D .
125
+ ```
126
+
127
+ `--discover` walks the immediate sub-directories of each given path and watches
128
+ those that already contain a `wup.yaml` (hidden and vendor folders such as
129
+ `node_modules`, `.venv`, `dist` and `build` are skipped). A relative `--deps`
130
+ path is resolved per project, so each project uses its own `deps.json`.
131
+
132
+ > The live `--dashboard` and single `--config` options apply to single-project
133
+ > runs only; when watching multiple projects each uses its own `wup.yaml`.
134
+
111
135
  ### Initialize Configuration
112
136
 
113
137
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wup"
7
- version = "0.2.68"
7
+ version = "0.2.70"
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,117 @@
1
+ """Tests for simultaneous multi-project watching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from wup.cli import _discover_projects, _resolve_project_paths
8
+ from wup.config import detect_watch_paths, get_default_config
9
+ from wup.core import WupWatcher
10
+ from wup.models.config import ProjectConfig, WatchConfig, WupConfig
11
+ from wup.multi import MultiProjectWatcher
12
+
13
+
14
+ def _make_project(root: Path, name: str, src: str = "src") -> Path:
15
+ """Create a project dir with a wup.yaml and one watchable source folder."""
16
+ proj = root / name
17
+ (proj / src).mkdir(parents=True)
18
+ (proj / src / "main.py").write_text("print('hi')\n")
19
+ (proj / "wup.yaml").write_text(
20
+ f"project:\n name: {name}\nwatch:\n paths:\n - {src}/**\n"
21
+ )
22
+ return proj
23
+
24
+
25
+ def _watcher(project_root: Path, paths: list[str]) -> WupWatcher:
26
+ cfg = WupConfig(
27
+ project=ProjectConfig(name=project_root.name),
28
+ watch=WatchConfig(paths=paths),
29
+ )
30
+ return WupWatcher(
31
+ project_root=str(project_root),
32
+ deps_file=str(project_root / "deps.json"),
33
+ config=cfg,
34
+ )
35
+
36
+
37
+ def test_detect_watch_paths_uses_existing_dirs(tmp_path: Path) -> None:
38
+ (tmp_path / "services").mkdir()
39
+ (tmp_path / "lib").mkdir()
40
+ paths = detect_watch_paths(tmp_path)
41
+ assert "services/**" in paths
42
+ assert "lib/**" in paths
43
+ # A non-existent common dir is not emitted.
44
+ assert "app/**" not in paths
45
+
46
+
47
+ def test_detect_watch_paths_falls_back_when_nothing_matches(tmp_path: Path) -> None:
48
+ assert detect_watch_paths(tmp_path) == ["app/**", "src/**", "routes/**"]
49
+
50
+
51
+ def test_default_config_watches_only_real_dirs(tmp_path: Path) -> None:
52
+ (tmp_path / "services").mkdir()
53
+ cfg = get_default_config(tmp_path)
54
+ assert cfg.watch.paths == ["services/**"]
55
+
56
+
57
+ def test_discover_finds_subprojects(tmp_path: Path) -> None:
58
+ _make_project(tmp_path, "alpha")
59
+ _make_project(tmp_path, "beta")
60
+ (tmp_path / "not_a_project").mkdir() # no wup.yaml -> skipped
61
+
62
+ discovered = {p.name for p in _discover_projects(tmp_path)}
63
+ assert discovered == {"alpha", "beta"}
64
+
65
+
66
+ def test_discover_skips_vendor_and_hidden(tmp_path: Path) -> None:
67
+ _make_project(tmp_path, "alpha")
68
+ _make_project(tmp_path, "node_modules") # vendored -> skipped
69
+ _make_project(tmp_path, ".hidden") # hidden -> skipped
70
+
71
+ discovered = {p.name for p in _discover_projects(tmp_path)}
72
+ assert discovered == {"alpha"}
73
+
74
+
75
+ def test_resolve_paths_dedupes(tmp_path: Path) -> None:
76
+ _make_project(tmp_path, "alpha")
77
+ resolved = _resolve_project_paths(
78
+ [str(tmp_path / "alpha"), str(tmp_path / "alpha")], discover=False
79
+ )
80
+ assert len(resolved) == 1
81
+
82
+
83
+ def test_prepare_observer_none_when_no_valid_paths(tmp_path: Path) -> None:
84
+ watcher = _watcher(tmp_path, paths=["does-not-exist/**"])
85
+ assert watcher.prepare_observer() is None
86
+
87
+
88
+ def test_multi_watcher_returns_false_when_all_invalid(tmp_path: Path) -> None:
89
+ w1 = _watcher(tmp_path, paths=["missing-a/**"])
90
+ w2 = _watcher(tmp_path, paths=["missing-b/**"])
91
+ assert MultiProjectWatcher([w1, w2]).start_watching() is False
92
+
93
+
94
+ def test_multi_watcher_starts_observers_for_valid_projects(tmp_path: Path) -> None:
95
+ alpha = _make_project(tmp_path, "alpha")
96
+ beta = _make_project(tmp_path, "beta")
97
+ w1 = _watcher(alpha, paths=["src/**"])
98
+ w2 = _watcher(beta, paths=["src/**"])
99
+
100
+ multi = MultiProjectWatcher([w1, w2])
101
+ observers = []
102
+ active = []
103
+ for watcher in multi.watchers:
104
+ watcher.start_background_tasks()
105
+ obs = watcher.prepare_observer()
106
+ if obs is not None:
107
+ observers.append(obs)
108
+ active.append(watcher)
109
+
110
+ try:
111
+ assert len(active) == 2
112
+ assert all(obs.is_alive() for obs in observers)
113
+ finally:
114
+ for obs in observers:
115
+ obs.stop()
116
+ for obs in observers:
117
+ obs.join()
@@ -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]
@@ -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
- 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
+ )
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, service, endpoints):
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("users", ["/connect-users", "/connect-users/1"])
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
- "backend",
1087
- ["/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
+ )
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(differ.run_for_service("svc", ["/x"]))
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("frontend", ["/a", "/b", "/c", "/d"])
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.68"
10
+ __version__ = "0.2.70"
11
11
  __author__ = "Tom Sapletta"
12
12
 
13
13
  from .config import load_config, save_config, get_default_config