wup 0.2.26__tar.gz → 0.2.28__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 (36) hide show
  1. {wup-0.2.26/wup.egg-info → wup-0.2.28}/PKG-INFO +14 -13
  2. {wup-0.2.26 → wup-0.2.28}/README.md +13 -12
  3. {wup-0.2.26 → wup-0.2.28}/pyproject.toml +1 -1
  4. {wup-0.2.26 → wup-0.2.28}/tests/test_testql_monitor.py +47 -0
  5. {wup-0.2.26 → wup-0.2.28}/tests/test_testql_watcher.py +80 -1
  6. {wup-0.2.26 → wup-0.2.28}/wup/__init__.py +1 -1
  7. {wup-0.2.26 → wup-0.2.28}/wup/cli.py +21 -10
  8. {wup-0.2.26 → wup-0.2.28}/wup/config.py +10 -2
  9. {wup-0.2.26 → wup-0.2.28}/wup/models/config.py +7 -2
  10. {wup-0.2.26 → wup-0.2.28}/wup/testql_monitor.py +111 -20
  11. {wup-0.2.26 → wup-0.2.28}/wup/testql_watcher.py +126 -18
  12. {wup-0.2.26 → wup-0.2.28}/wup/visual_diff.py +26 -3
  13. {wup-0.2.26 → wup-0.2.28}/wup/web_client.py +8 -1
  14. {wup-0.2.26 → wup-0.2.28/wup.egg-info}/PKG-INFO +14 -13
  15. {wup-0.2.26 → wup-0.2.28}/LICENSE +0 -0
  16. {wup-0.2.26 → wup-0.2.28}/setup.cfg +0 -0
  17. {wup-0.2.26 → wup-0.2.28}/tests/test_e2e.py +0 -0
  18. {wup-0.2.26 → wup-0.2.28}/tests/test_monitoring_manifest.py +0 -0
  19. {wup-0.2.26 → wup-0.2.28}/tests/test_web_client.py +0 -0
  20. {wup-0.2.26 → wup-0.2.28}/tests/test_wup.py +0 -0
  21. {wup-0.2.26 → wup-0.2.28}/wup/_ast_detector.py +0 -0
  22. {wup-0.2.26 → wup-0.2.28}/wup/_hash_detector.py +0 -0
  23. {wup-0.2.26 → wup-0.2.28}/wup/_yaml_detector.py +0 -0
  24. {wup-0.2.26 → wup-0.2.28}/wup/anomaly_detector.py +0 -0
  25. {wup-0.2.26 → wup-0.2.28}/wup/anomaly_models.py +0 -0
  26. {wup-0.2.26 → wup-0.2.28}/wup/assistant.py +0 -0
  27. {wup-0.2.26 → wup-0.2.28}/wup/core.py +0 -0
  28. {wup-0.2.26 → wup-0.2.28}/wup/dependency_mapper.py +0 -0
  29. {wup-0.2.26 → wup-0.2.28}/wup/models/__init__.py +0 -0
  30. {wup-0.2.26 → wup-0.2.28}/wup/monitoring_manifest.py +0 -0
  31. {wup-0.2.26 → wup-0.2.28}/wup/testql_discovery.py +0 -0
  32. {wup-0.2.26 → wup-0.2.28}/wup.egg-info/SOURCES.txt +0 -0
  33. {wup-0.2.26 → wup-0.2.28}/wup.egg-info/dependency_links.txt +0 -0
  34. {wup-0.2.26 → wup-0.2.28}/wup.egg-info/entry_points.txt +0 -0
  35. {wup-0.2.26 → wup-0.2.28}/wup.egg-info/requires.txt +0 -0
  36. {wup-0.2.26 → wup-0.2.28}/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.26
3
+ Version: 0.2.28
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
@@ -29,17 +29,17 @@ Dynamic: license-file
29
29
 
30
30
  ## AI Cost Tracking
31
31
 
32
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.26-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
33
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$1.95-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-15.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
32
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.28-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
33
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.27-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-16.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
34
34
 
35
- - 🤖 **LLM usage:** $1.9540 (36 commits)
36
- - 👤 **Human dev:** ~$1532 (15.3h @ $100/h, 30min dedup)
35
+ - 🤖 **LLM usage:** $2.2653 (38 commits)
36
+ - 👤 **Human dev:** ~$1632 (16.3h @ $100/h, 30min dedup)
37
37
 
38
- Generated on 2026-05-16 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
38
+ Generated on 2026-05-17 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
39
39
 
40
40
  ---
41
41
 
42
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.26-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
42
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.28-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
43
43
 
44
44
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
45
45
 
@@ -83,7 +83,7 @@ wup assistant --quick --template fastapi
83
83
  # 3. Build dependency map (one-time setup)
84
84
  wup map-deps ./my-project
85
85
 
86
- # 4. Start watching for changes
86
+ # 4. Start watching (TestQL + live probes every 60s by default)
87
87
  wup watch ./my-project
88
88
 
89
89
  # 5. Start with live dashboard
@@ -110,14 +110,18 @@ wup map-deps ./my-project --output my-deps.json
110
110
  ### Watch Project
111
111
 
112
112
  ```bash
113
- # Basic watching (uses wup.yaml if present)
113
+ # Basic watching: TestQL mode + live HTTP probes every 60s (uses wup.yaml if present)
114
114
  wup watch ./my-project
115
115
 
116
+ # Legacy HTTP-only watcher (no TestQL, no periodic probes unless configured)
117
+ wup watch ./my-project --mode default --probe-interval 0
118
+
116
119
  # With custom settings
117
120
  wup watch ./my-project \
118
121
  --cpu-throttle 0.5 \
119
122
  --debounce 3 \
120
- --cooldown 600
123
+ --cooldown 600 \
124
+ --probe-interval 120
121
125
 
122
126
  # With live dashboard
123
127
  wup watch ./my-project --dashboard
@@ -125,9 +129,6 @@ wup watch ./my-project --dashboard
125
129
  # Use specific config file
126
130
  wup watch ./my-project --config custom-config.yaml
127
131
 
128
- # TestQL mode
129
- wup watch ./my-project --mode testql
130
-
131
132
  # Discover endpoints from TestQL scenarios
132
133
  wup testql-endpoints /path/to/scenarios --output testql-deps.json
133
134
  ```
@@ -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.26-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$1.95-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-15.3h-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.28-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.27-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-16.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $1.9540 (36 commits)
10
- - 👤 **Human dev:** ~$1532 (15.3h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $2.2653 (38 commits)
10
+ - 👤 **Human dev:** ~$1632 (16.3h @ $100/h, 30min dedup)
11
11
 
12
- Generated on 2026-05-16 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
12
+ Generated on 2026-05-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.26-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.28-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
 
@@ -57,7 +57,7 @@ wup assistant --quick --template fastapi
57
57
  # 3. Build dependency map (one-time setup)
58
58
  wup map-deps ./my-project
59
59
 
60
- # 4. Start watching for changes
60
+ # 4. Start watching (TestQL + live probes every 60s by default)
61
61
  wup watch ./my-project
62
62
 
63
63
  # 5. Start with live dashboard
@@ -84,14 +84,18 @@ wup map-deps ./my-project --output my-deps.json
84
84
  ### Watch Project
85
85
 
86
86
  ```bash
87
- # Basic watching (uses wup.yaml if present)
87
+ # Basic watching: TestQL mode + live HTTP probes every 60s (uses wup.yaml if present)
88
88
  wup watch ./my-project
89
89
 
90
+ # Legacy HTTP-only watcher (no TestQL, no periodic probes unless configured)
91
+ wup watch ./my-project --mode default --probe-interval 0
92
+
90
93
  # With custom settings
91
94
  wup watch ./my-project \
92
95
  --cpu-throttle 0.5 \
93
96
  --debounce 3 \
94
- --cooldown 600
97
+ --cooldown 600 \
98
+ --probe-interval 120
95
99
 
96
100
  # With live dashboard
97
101
  wup watch ./my-project --dashboard
@@ -99,9 +103,6 @@ wup watch ./my-project --dashboard
99
103
  # Use specific config file
100
104
  wup watch ./my-project --config custom-config.yaml
101
105
 
102
- # TestQL mode
103
- wup watch ./my-project --mode testql
104
-
105
106
  # Discover endpoints from TestQL scenarios
106
107
  wup testql-endpoints /path/to/scenarios --output testql-deps.json
107
108
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wup"
7
- version = "0.2.26"
7
+ version = "0.2.28"
8
8
  description = "WUP (What's Up) - Intelligent file watcher for regression testing in large projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -34,6 +34,31 @@ API[1]{method, endpoint, expected_status}:
34
34
  assert is_monitoring_probe(probes[0])
35
35
 
36
36
 
37
+ def test_firmware_plugin_health_on_8202_not_live_probe():
38
+ probe = ProbeTarget(url="http://localhost:8202/api/v1/plugins/modbus-io/health")
39
+ assert not is_monitoring_probe(probe)
40
+ direct = ProbeTarget(url="http://localhost:8202/health")
41
+ assert is_monitoring_probe(direct)
42
+
43
+
44
+ def test_connect_api_paths_on_8100_are_not_monitoring_probes():
45
+ probe = ProbeTarget(url="http://localhost:8100/api/id/health")
46
+ assert not is_monitoring_probe(probe)
47
+ assert assign_probe_to_service(
48
+ probe,
49
+ [ServiceConfig(name="backend", paths=["backend/**", "api/**"])],
50
+ ) != "backend"
51
+
52
+
53
+ def test_connect_health_on_8103_not_assigned_to_backend():
54
+ services = [
55
+ ServiceConfig(name="frontend", paths=["frontend/**"]),
56
+ ServiceConfig(name="backend", paths=["backend/**"]),
57
+ ]
58
+ probe = ProbeTarget(url="http://localhost:8103/api/id/health")
59
+ assert assign_probe_to_service(probe, services) is None
60
+
61
+
37
62
  def test_assign_firmware_service():
38
63
  services = [
39
64
  ServiceConfig(name="frontend", paths=["frontend/**"]),
@@ -87,6 +112,28 @@ def test_monitor_merges_config_and_service_map():
87
112
  assert "http://localhost:8100/firmware/api/v1/execution/logs" in urls
88
113
 
89
114
 
115
+ def test_probes_for_service_ignores_non_health_extra_paths():
116
+ with tempfile.TemporaryDirectory() as tmpdir:
117
+ root = Path(tmpdir)
118
+ cfg = WupConfig(
119
+ project=ProjectConfig(name="demo"),
120
+ services=[ServiceConfig(name="backend", paths=["backend/**"])],
121
+ watch=WatchConfig(),
122
+ testql=TestQLConfig(
123
+ base_url="http://localhost:8100",
124
+ api_base_url="http://localhost:8101",
125
+ endpoints_by_service={"backend": ["http://localhost:8101/api/v3/health"]},
126
+ ),
127
+ )
128
+ monitor = TestQLMonitor(root, cfg)
129
+ probes = monitor.probes_for_service(
130
+ "backend",
131
+ ["/connect-config", "http://localhost:8101/connect-config"],
132
+ )
133
+ urls = {p.url for p in probes}
134
+ assert urls == {"http://localhost:8101/api/v3/health"}
135
+
136
+
90
137
  def test_live_probe_failure_updates_health():
91
138
  with tempfile.TemporaryDirectory() as tmpdir:
92
139
  root = Path(tmpdir)
@@ -6,7 +6,14 @@ from pathlib import Path
6
6
  from subprocess import CompletedProcess
7
7
 
8
8
  from wup.testql_watcher import TestQLWatcher
9
- from wup.models.config import WupConfig, ProjectConfig, TestQLConfig, VisualDiffConfig
9
+ from wup.models.config import (
10
+ ProjectConfig,
11
+ ServiceConfig,
12
+ TestQLConfig,
13
+ VisualDiffConfig,
14
+ WatchConfig,
15
+ WupConfig,
16
+ )
10
17
 
11
18
 
12
19
  def test_process_changed_file_creates_track_on_failure():
@@ -97,6 +104,7 @@ def test_config_endpoints_use_base_url_from_yaml_config():
97
104
  root = Path(tmpdir)
98
105
  cfg = WupConfig(
99
106
  project=ProjectConfig(name="demo"),
107
+ services=[ServiceConfig(name="connect-config", paths=["connect-config/**"])],
100
108
  testql=TestQLConfig(
101
109
  base_url="http://localhost:8100",
102
110
  explicit_endpoints=["/connect-config"],
@@ -122,6 +130,7 @@ def test_config_endpoints_use_base_url_from_env_when_yaml_missing():
122
130
  root = Path(tmpdir)
123
131
  cfg = WupConfig(
124
132
  project=ProjectConfig(name="demo"),
133
+ services=[ServiceConfig(name="connect-data", paths=["connect-data/**"])],
125
134
  testql=TestQLConfig(
126
135
  base_url="",
127
136
  base_url_env="WUP_BASE_URL",
@@ -201,6 +210,76 @@ def test_service_health_transitions_are_persisted():
201
210
  assert "up" in statuses
202
211
 
203
212
 
213
+ def test_normalize_fleet_health_entry_down_to_degraded():
214
+ with tempfile.TemporaryDirectory() as tmpdir:
215
+ root = Path(tmpdir)
216
+ health_path = root / ".wup" / "service-health.json"
217
+ health_path.parent.mkdir(parents=True)
218
+ health_path.write_text(
219
+ json.dumps(
220
+ {
221
+ "demo": {
222
+ "status": "down",
223
+ "stage": "health_scenario",
224
+ "message": "partial",
225
+ }
226
+ }
227
+ ),
228
+ encoding="utf-8",
229
+ )
230
+ cfg = WupConfig(
231
+ project=ProjectConfig(name="demo"),
232
+ services=[ServiceConfig(name="frontend", paths=["frontend/**"])],
233
+ watch=WatchConfig(),
234
+ testql=TestQLConfig(health_scenario_strict=False),
235
+ )
236
+ TestQLWatcher(
237
+ project_root=str(root),
238
+ deps_file=str(root / "deps.json"),
239
+ scenarios_dir="testql-scenarios",
240
+ track_dir=".wup/tracks",
241
+ config=cfg,
242
+ )
243
+ state = json.loads(health_path.read_text(encoding="utf-8"))
244
+ assert state["demo"]["status"] == "degraded"
245
+
246
+
247
+ def test_fleet_health_scenario_non_strict_records_degraded_not_down():
248
+ with tempfile.TemporaryDirectory() as tmpdir:
249
+ root = Path(tmpdir)
250
+ scenario_dir = root / "testql-scenarios"
251
+ scenario_dir.mkdir(parents=True, exist_ok=True)
252
+ (scenario_dir / "fleet.testql.toon.yaml").write_text("name: fleet\n", encoding="utf-8")
253
+
254
+ cfg = WupConfig(
255
+ project=ProjectConfig(name="demo"),
256
+ services=[ServiceConfig(name="frontend", paths=["frontend/**"])],
257
+ watch=WatchConfig(),
258
+ testql=TestQLConfig(
259
+ scenario_dir="testql-scenarios",
260
+ health_scenario="fleet.testql.toon.yaml",
261
+ health_scenario_strict=False,
262
+ ),
263
+ )
264
+ watcher = TestQLWatcher(
265
+ project_root=str(root),
266
+ deps_file=str(root / "deps.json"),
267
+ scenarios_dir="testql-scenarios",
268
+ track_dir=".wup/tracks",
269
+ config=cfg,
270
+ )
271
+ watcher._run_testql = lambda args, timeout: CompletedProcess( # type: ignore[method-assign]
272
+ args=args,
273
+ returncode=1,
274
+ stdout='{"passed": 1, "failed": 1, "errors": ["L1: bad"]}',
275
+ stderr="",
276
+ )
277
+ assert asyncio.run(watcher._run_fleet_health_scenario()) is True
278
+ state = json.loads((root / ".wup" / "service-health.json").read_text(encoding="utf-8"))
279
+ assert state["demo"]["status"] == "degraded"
280
+ assert state["demo"]["stage"] == "health_scenario"
281
+
282
+
204
283
  def test_visual_differ_disabled_by_default():
205
284
  """visual_differ exists but is disabled (no-op) when visual_diff.enabled=False."""
206
285
  with tempfile.TemporaryDirectory() as tmpdir:
@@ -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.26"
10
+ __version__ = "0.2.28"
11
11
  __author__ = "Tom Sapletta"
12
12
 
13
13
  from .config import load_config, save_config, get_default_config
@@ -31,8 +31,16 @@ def watch(
31
31
  debounce: int = typer.Option(2, "--debounce", "-b", help="Debounce time in seconds"),
32
32
  cooldown: int = typer.Option(300, "--cooldown", "-t", help="Test cooldown in seconds"),
33
33
  dashboard: bool = typer.Option(False, "--dashboard", help="Enable live dashboard"),
34
- mode: str = typer.Option("default", "--mode", help="Watcher mode: default or testql"),
35
- scenarios_dir: str = typer.Option("testql-scenarios", "--scenarios-dir", help="Directory with TestQL scenario files"),
34
+ mode: str = typer.Option(
35
+ "testql",
36
+ "--mode",
37
+ help="Watcher mode: testql (default) or default (HTTP-only, no TestQL)",
38
+ ),
39
+ scenarios_dir: Optional[str] = typer.Option(
40
+ None,
41
+ "--scenarios-dir",
42
+ help="TestQL scenarios directory (default: testql.scenario_dir from wup.yaml)",
43
+ ),
36
44
  testql_bin: str = typer.Option("testql", "--testql-bin", help="TestQL executable name/path"),
37
45
  browser_service_url: Optional[str] = typer.Option(None, "--browser-service-url", help="HTTP endpoint for browser notifications"),
38
46
  track_dir: str = typer.Option(".wup/tracks", "--track-dir", help="Directory where error track JSON files are written"),
@@ -40,17 +48,16 @@ def watch(
40
48
  probe_interval: Optional[int] = typer.Option(
41
49
  None,
42
50
  "--probe-interval",
43
- help="Periodic live HTTP/TestQL probes in seconds (overrides testql.probe_interval_s)",
51
+ help="Periodic live HTTP probes in seconds (default: 60 in testql mode, or testql.probe_interval_s from wup.yaml; use 0 to disable)",
44
52
  ),
45
53
  config: Optional[str] = typer.Option(None, "--config", "-C", help="Path to wup.yaml config file"),
46
54
  ):
47
55
  """
48
- Watch project for file changes and run intelligent regression tests.
49
-
50
- Uses a 3-layer approach:
51
- 1. Detection: File watching with heuristics
52
- 2. Priority: Quick tests of related services (3 endpoints max)
53
- 3. Detail: Full tests with blame reports (only on failure)
56
+ Watch project for file changes and run regression tests.
57
+
58
+ Defaults (no extra flags): ``--mode testql`` and live probes every **60s**
59
+ (unless ``testql.probe_interval_s`` is set in wup.yaml). Use
60
+ ``--mode default`` for the legacy HTTP-only watcher without TestQL.
54
61
  """
55
62
  project_path = Path(project).resolve()
56
63
 
@@ -63,6 +70,10 @@ def watch(
63
70
  wup_config = load_config(project_path, config_path)
64
71
  if probe_interval is not None:
65
72
  wup_config.testql.probe_interval_s = int(probe_interval)
73
+ elif mode.lower() == "testql" and not wup_config.testql.probe_interval_s:
74
+ wup_config.testql.probe_interval_s = 60
75
+
76
+ effective_scenarios_dir = scenarios_dir or wup_config.testql.scenario_dir
66
77
 
67
78
  console.print(f"[bold cyan]🚀 WUP Watcher[/bold cyan]")
68
79
  console.print(f"[dim]Project: {wup_config.project.name}[/dim]")
@@ -92,7 +103,7 @@ def watch(
92
103
  cpu_throttle=cpu_throttle,
93
104
  debounce_seconds=debounce,
94
105
  test_cooldown_seconds=cooldown,
95
- scenarios_dir=scenarios_dir,
106
+ scenarios_dir=effective_scenarios_dir,
96
107
  testql_bin=testql_bin,
97
108
  browser_service_url=browser_service_url,
98
109
  track_dir=track_dir,
@@ -178,9 +178,12 @@ def validate_config(raw: dict) -> WupConfig:
178
178
  endpoint_discovery=testql_raw.get("endpoint_discovery", True),
179
179
  probe_interval_s=int(testql_raw.get("probe_interval_s", 0) or 0),
180
180
  health_scenario=testql_raw.get("health_scenario", ""),
181
+ health_scenario_strict=bool(testql_raw.get("health_scenario_strict", False)),
181
182
  service_map_globs=testql_raw.get("service_map_globs", []),
182
183
  base_url=testql_raw.get("base_url", ""),
184
+ api_base_url=testql_raw.get("api_base_url", ""),
183
185
  base_url_env=testql_raw.get("base_url_env", "WUP_BASE_URL"),
186
+ service_base_urls=testql_raw.get("service_base_urls", {}),
184
187
  explicit_endpoints=testql_raw.get("explicit_endpoints", []),
185
188
  endpoints_by_service=testql_raw.get("endpoints_by_service", {})
186
189
  )
@@ -215,7 +218,8 @@ def validate_config(raw: dict) -> WupConfig:
215
218
  snapshot_dir=vd_raw.get("snapshot_dir", ".wup/visual-snapshots"),
216
219
  diff_dir=vd_raw.get("diff_dir", ".wup/visual-diffs"),
217
220
  pages=vd_raw.get("pages", []),
218
- pages_from_endpoints=vd_raw.get("pages_from_endpoints", True),
221
+ pages_from_endpoints=vd_raw.get("pages_from_endpoints", False),
222
+ max_pages=int(vd_raw.get("max_pages", 5)),
219
223
  threshold_added=int(vd_raw.get("threshold_added", 3)),
220
224
  threshold_removed=int(vd_raw.get("threshold_removed", 3)),
221
225
  threshold_changed=int(vd_raw.get("threshold_changed", 5)),
@@ -304,7 +308,7 @@ def save_config(config: WupConfig, output_path: Path):
304
308
  f"# wupbro (optional dashboard): pip install wupbro",
305
309
  f"#",
306
310
  f"# Quick Start:",
307
- f"# 1. wup watch . # Start watching",
311
+ f"# 1. wup watch . # TestQL + live probes every 60s",
308
312
  f"# 2. wup watch . --dashboard # With live dashboard",
309
313
  f"# 3. wup map-deps . # Build dependency map",
310
314
  f"#",
@@ -333,9 +337,12 @@ def save_config(config: WupConfig, output_path: Path):
333
337
  "endpoint_discovery": config.testql.endpoint_discovery,
334
338
  "probe_interval_s": config.testql.probe_interval_s,
335
339
  "health_scenario": config.testql.health_scenario,
340
+ "health_scenario_strict": config.testql.health_scenario_strict,
336
341
  "service_map_globs": config.testql.service_map_globs,
337
342
  "base_url": config.testql.base_url,
343
+ "api_base_url": config.testql.api_base_url,
338
344
  "base_url_env": config.testql.base_url_env,
345
+ "service_base_urls": config.testql.service_base_urls,
339
346
  "explicit_endpoints": config.testql.explicit_endpoints,
340
347
  "endpoints_by_service": config.testql.endpoints_by_service,
341
348
  },
@@ -349,6 +356,7 @@ def save_config(config: WupConfig, output_path: Path):
349
356
  "diff_dir": config.visual_diff.diff_dir,
350
357
  "pages": config.visual_diff.pages,
351
358
  "pages_from_endpoints": config.visual_diff.pages_from_endpoints,
359
+ "max_pages": config.visual_diff.max_pages,
352
360
  "threshold_added": config.visual_diff.threshold_added,
353
361
  "threshold_removed": config.visual_diff.threshold_removed,
354
362
  "threshold_changed": config.visual_diff.threshold_changed,
@@ -56,16 +56,20 @@ class TestStrategyConfig:
56
56
  @dataclass
57
57
  class TestQLConfig:
58
58
  """TestQL-specific configuration."""
59
+ __test__ = False
59
60
  scenario_dir: str = "scenarios/tests"
60
61
  smoke_scenario: str = "smoke.testql.toon.yaml"
61
62
  output_format: str = "json"
62
63
  extra_args: List[str] = field(default_factory=lambda: ["--timeout 10s"])
63
64
  endpoint_discovery: bool = True # Merge health probes from scenarios + service maps
64
65
  probe_interval_s: int = 0 # Periodic live probes for all services (0 = file-change only)
65
- health_scenario: str = "" # Optional TestQL scenario run live (not --dry-run) on each quick pass
66
+ health_scenario: str = "" # Fleet TestQL scenario on each periodic probe cycle (live run)
67
+ health_scenario_strict: bool = False # If false, fleet scenario failure is logged but does not block per-service probes
66
68
  service_map_globs: List[str] = field(default_factory=list) # e.g. testql-testing/service-map/*.yaml
67
69
  base_url: str = ""
70
+ api_base_url: str = "" # Core API (c2004: http://localhost:8101) — used for backend probes
68
71
  base_url_env: str = "WUP_BASE_URL"
72
+ service_base_urls: Dict[str, str] = field(default_factory=dict) # optional per-service override
69
73
  explicit_endpoints: List[str] = field(default_factory=list)
70
74
  endpoints_by_service: Dict[str, List[str]] = field(default_factory=dict)
71
75
 
@@ -81,7 +85,8 @@ class VisualDiffConfig:
81
85
  snapshot_dir: str = ".wup/visual-snapshots"
82
86
  diff_dir: str = ".wup/visual-diffs"
83
87
  pages: List[str] = field(default_factory=list) # explicit page paths to scan
84
- pages_from_endpoints: bool = True # infer pages from explicit_endpoints
88
+ pages_from_endpoints: bool = True
89
+ max_pages: int = 5 # cap DOM scans per service per file change
85
90
  threshold_added: int = 3 # min added nodes to report
86
91
  threshold_removed: int = 3 # min removed nodes to report
87
92
  threshold_changed: int = 5 # min changed attrs to report
@@ -22,6 +22,18 @@ _HEALTH_HINT = re.compile(
22
22
  r"(/health|/healthz|/ready|/live|/status|/openapi\.json|/execution/status|/execution/logs)",
23
23
  re.IGNORECASE,
24
24
  )
25
+ # Connect module APIs live on :8103+ — not valid health probes on frontend proxy :8100
26
+ _CONNECT_API_PREFIXES = (
27
+ "/api/id",
28
+ "/api/manager",
29
+ "/api/scenario",
30
+ "/api/test",
31
+ "/api/template",
32
+ "/api/cql",
33
+ "/api/v1/data",
34
+ "/api/v2/menu",
35
+ )
36
+ _PATH_TOKEN_BLOCKLIST = frozenset({"api", "app", "src", "lib", "bin", "dist", "out"})
25
37
 
26
38
 
27
39
  @dataclass(frozen=True)
@@ -102,12 +114,41 @@ def parse_service_map_probes(map_path: Path) -> List[ProbeTarget]:
102
114
  return probes
103
115
 
104
116
 
117
+ def _connect_module_api_on_frontend_proxy(probe: ProbeTarget) -> bool:
118
+ """True when a connect-* API path would be wrongly probed via :8100."""
119
+ if not probe.url.startswith("http"):
120
+ return False
121
+ parsed = urlparse(probe.url)
122
+ if parsed.port not in (None, 8100):
123
+ return False
124
+ path = (parsed.path or "").lower()
125
+ return any(path.startswith(prefix) for prefix in _CONNECT_API_PREFIXES)
126
+
127
+
128
+ def _firmware_plugin_probe_without_runtime(probe: ProbeTarget) -> bool:
129
+ """Plugin health on :8202 requires loaded plugins — skip for bare simulator live probes."""
130
+ if not probe.url.startswith("http"):
131
+ return False
132
+ parsed = urlparse(probe.url)
133
+ if parsed.port != 8202:
134
+ return False
135
+ path = (parsed.path or "").lower()
136
+ return "/api/v1/plugins/" in path and path.endswith("/health")
137
+
138
+
105
139
  def is_monitoring_probe(probe: ProbeTarget) -> bool:
106
140
  """True when this endpoint should be used for live service health checks."""
141
+ if _connect_module_api_on_frontend_proxy(probe):
142
+ return False
143
+ if _firmware_plugin_probe_without_runtime(probe):
144
+ return False
107
145
  if probe.url.startswith("http"):
108
146
  path = urlparse(probe.url).path or probe.url
109
147
  else:
110
148
  path = probe.url
149
+ path_lower = path.lower()
150
+ if any(path_lower.startswith(prefix) for prefix in _CONNECT_API_PREFIXES):
151
+ return False
111
152
  if _HEALTH_HINT.search(path):
112
153
  return True
113
154
  # Short GET smoke paths (/, /health) without heavy write APIs
@@ -128,14 +169,38 @@ def _service_path_patterns(services: Sequence[ServiceConfig]) -> Dict[str, List[
128
169
 
129
170
  def assign_probe_to_service(probe: ProbeTarget, services: Sequence[ServiceConfig]) -> Optional[str]:
130
171
  """Map a probe URL/path to a configured WUP service name."""
172
+ wup_names = {s.name.lower() for s in services}
131
173
  path = urlparse(probe.url).path if probe.url.startswith("http") else probe.url
132
174
  path_lower = path.lower()
133
175
 
176
+ if probe.url.startswith("http"):
177
+ parsed = urlparse(probe.url)
178
+ port = parsed.port
179
+ if port == 8101 and "backend" in wup_names:
180
+ return next(s.name for s in services if s.name.lower() == "backend")
181
+ if port == 8202:
182
+ for svc in services:
183
+ if "firmware" in svc.name.lower():
184
+ return svc.name
185
+ if port == 8100:
186
+ if path_lower.startswith("/firmware"):
187
+ for svc in services:
188
+ if "firmware" in svc.name.lower():
189
+ return svc.name
190
+ if "frontend" in wup_names:
191
+ return next(s.name for s in services if s.name.lower() == "frontend")
192
+ # Connect-* backends on 8103+ — only if a matching WUP service exists
193
+ for svc in services:
194
+ token = svc.name.lower().replace("_", "-")
195
+ if token.startswith("connect-") and token.replace("connect-", "") in path_lower:
196
+ return svc.name
197
+ return None
198
+
134
199
  best: Optional[str] = None
135
200
  best_len = -1
136
201
  for svc in services:
137
202
  for token in _service_path_patterns([svc]).get(svc.name, []):
138
- if len(token) < 3:
203
+ if len(token) < 4 or token in _PATH_TOKEN_BLOCKLIST:
139
204
  continue
140
205
  if token in path_lower and len(token) > best_len:
141
206
  best = svc.name
@@ -148,7 +213,7 @@ def assign_probe_to_service(probe: ProbeTarget, services: Sequence[ServiceConfig
148
213
  for svc in services:
149
214
  if "firmware" in svc.name.lower():
150
215
  return svc.name
151
- if path_lower.startswith("/api/"):
216
+ if path_lower.startswith("/api/v3"):
152
217
  for svc in services:
153
218
  if svc.name.lower() in {"backend", "api"}:
154
219
  return svc.name
@@ -193,23 +258,28 @@ class TestQLMonitor:
193
258
  seen[service].add(key)
194
259
  by_service[service].append(probe)
195
260
 
196
- # 1) Config-declared endpoints (paths or full URLs)
197
- base = self._resolve_base_url()
261
+ # 1) Config-declared endpoints (paths or full URLs) — per-service base URL
198
262
  for svc_name, paths in (self.config.testql.endpoints_by_service or {}).items():
263
+ base = self._resolve_base_url_for_service(svc_name)
199
264
  for path in paths:
200
265
  url = self._probeable_url(path, base)
201
266
  if not url:
202
267
  continue
203
268
  probe = ProbeTarget(url=url, source="wup.yaml:endpoints_by_service")
204
- add(svc_name, probe)
269
+ if is_monitoring_probe(probe):
270
+ add(svc_name, probe)
205
271
 
206
272
  for path in self.config.testql.explicit_endpoints or []:
273
+ probe = ProbeTarget(url=path, source="wup.yaml:explicit_endpoints")
274
+ assigned = assign_probe_to_service(probe, self.config.services)
275
+ if not assigned:
276
+ continue
277
+ base = self._resolve_base_url_for_service(assigned)
207
278
  url = self._probeable_url(path, base)
208
279
  if not url:
209
280
  continue
210
281
  probe = ProbeTarget(url=url, source="wup.yaml:explicit_endpoints")
211
- assigned = assign_probe_to_service(probe, self.config.services)
212
- if assigned:
282
+ if is_monitoring_probe(probe):
213
283
  add(assigned, probe)
214
284
 
215
285
  if not self.config.testql.endpoint_discovery:
@@ -235,6 +305,19 @@ class TestQLMonitor:
235
305
 
236
306
  return by_service
237
307
 
308
+ def _resolve_base_url_for_service(self, service: str) -> str:
309
+ tq = self.config.testql
310
+ overrides = getattr(tq, "service_base_urls", None) or {}
311
+ if isinstance(overrides, dict):
312
+ override = (overrides.get(service) or "").strip().rstrip("/")
313
+ if override:
314
+ return override
315
+ if service.lower() in {"backend", "api"}:
316
+ api_base = (getattr(tq, "api_base_url", None) or "").strip().rstrip("/")
317
+ if api_base:
318
+ return api_base
319
+ return self._resolve_base_url()
320
+
238
321
  def _probeable_url(self, path: str, base: str) -> Optional[str]:
239
322
  if path.startswith("http://") or path.startswith("https://"):
240
323
  return path
@@ -245,7 +328,7 @@ class TestQLMonitor:
245
328
  def probes_for_service(self, service: str, extra_paths: Iterable[str] = ()) -> List[ProbeTarget]:
246
329
  """Merged probe list for one service (discovery + config + caller extras)."""
247
330
  discovered = self.discover_probes_by_service().get(service, [])
248
- base = self._resolve_base_url()
331
+ base = self._resolve_base_url_for_service(service)
249
332
  merged: List[ProbeTarget] = list(discovered)
250
333
  keys = {f"{p.method}:{p.url}" for p in merged}
251
334
 
@@ -253,24 +336,31 @@ class TestQLMonitor:
253
336
  url = self._probeable_url(path, base)
254
337
  if not url:
255
338
  continue
256
- key = f"GET:{url}"
257
- if key in keys:
258
- continue
259
- keys.add(key)
260
- merged.append(ProbeTarget(url=url, source="runtime"))
261
-
262
- for path in self.config.testql.endpoints_by_service.get(service, []):
263
- url = self._probeable_url(path, base)
264
- if not url:
339
+ probe = ProbeTarget(url=url, source="runtime")
340
+ if not is_monitoring_probe(probe):
265
341
  continue
266
- key = f"GET:{url}"
342
+ key = f"{probe.method}:{probe.url}"
267
343
  if key in keys:
268
344
  continue
269
345
  keys.add(key)
270
- merged.append(ProbeTarget(url=url, source="wup.yaml"))
346
+ merged.append(probe)
271
347
 
272
348
  return [p for p in merged if p.url.startswith("http://") or p.url.startswith("https://")]
273
349
 
350
+ @staticmethod
351
+ def _sort_probes_for_live(probes: Sequence[ProbeTarget]) -> List[ProbeTarget]:
352
+ """Prefer wup.yaml endpoints before scenario discovery for pass/fail."""
353
+
354
+ def rank(probe: ProbeTarget) -> Tuple[int, str]:
355
+ source = probe.source or ""
356
+ if source.startswith("wup.yaml:endpoints_by_service"):
357
+ return (0, probe.url)
358
+ if source.startswith("wup.yaml:explicit_endpoints"):
359
+ return (1, probe.url)
360
+ return (2, probe.url)
361
+
362
+ return sorted(probes, key=rank)
363
+
274
364
  def run_probes(
275
365
  self,
276
366
  service: str,
@@ -284,7 +374,8 @@ class TestQLMonitor:
284
374
  return True, ""
285
375
 
286
376
  failed: List[str] = []
287
- for probe in list(probes)[:max_count]:
377
+ ordered = self._sort_probes_for_live(probes)
378
+ for probe in ordered[:max_count]:
288
379
  ok, detail = probe.probe(timeout_s=timeout_s)
289
380
  if ok:
290
381
  continue
@@ -101,6 +101,27 @@ class TestQLWatcher(WupWatcher):
101
101
  self.visual_differ = VisualDiffer(project_root, config.visual_diff) if config and config.visual_diff else None
102
102
  self.web_client = WebClient(config.web) if config and getattr(config, "web", None) else WebClient()
103
103
  self._probe_thread = None
104
+ self._normalize_fleet_health_entry()
105
+
106
+ def _normalize_fleet_health_entry(self) -> None:
107
+ """Upgrade stale fleet ``down`` to ``degraded`` when health_scenario is non-strict."""
108
+ if not self.config:
109
+ return
110
+ strict = bool(getattr(self.config.testql, "health_scenario_strict", False))
111
+ if strict:
112
+ return
113
+ fleet = self.config.project.name
114
+ entry = self.service_health.get(fleet)
115
+ if not isinstance(entry, dict):
116
+ return
117
+ if entry.get("stage") != "health_scenario":
118
+ return
119
+ if str(entry.get("status", "")).lower() != "down":
120
+ return
121
+ entry = dict(entry)
122
+ entry["status"] = "degraded"
123
+ self.service_health[fleet] = entry
124
+ self._save_service_health()
104
125
 
105
126
  def _load_service_health(self) -> Dict[str, Dict]:
106
127
  if not self.health_state_path.exists():
@@ -188,17 +209,51 @@ class TestQLWatcher(WupWatcher):
188
209
  return [token for token in raw_tokens if len(token) >= 3]
189
210
 
190
211
  def _get_config_endpoints_for_service(self, service: str) -> List[str]:
212
+ """Endpoints for *service* only — never attach all explicit_endpoints to every service."""
213
+ from .testql_monitor import ProbeTarget, assign_probe_to_service
214
+
191
215
  by_service = self.config.testql.endpoints_by_service or {}
192
216
  explicit = self.config.testql.explicit_endpoints or []
193
217
 
194
- service_specific = by_service.get(service, [])
195
218
  merged: List[str] = []
196
- for endpoint in [*service_specific, *explicit]:
197
- endpoint_url = self._to_full_url(endpoint)
198
- if endpoint_url not in merged:
219
+ for endpoint in by_service.get(service, []):
220
+ endpoint_url = self._to_full_url_for_service(service, endpoint)
221
+ if endpoint_url and endpoint_url not in merged:
199
222
  merged.append(endpoint_url)
223
+
224
+ for endpoint in explicit:
225
+ probe = ProbeTarget(
226
+ url=self._to_full_url_for_service(service, endpoint) or endpoint,
227
+ source="wup.yaml:explicit_endpoints",
228
+ )
229
+ if assign_probe_to_service(probe, self.config.services) == service:
230
+ if probe.url not in merged:
231
+ merged.append(probe.url)
200
232
  return merged
201
233
 
234
+ def _to_full_url_for_service(self, service: str, endpoint: str) -> str:
235
+ if endpoint.startswith("http://") or endpoint.startswith("https://"):
236
+ return endpoint
237
+ base = self._resolve_base_url_for_service(service)
238
+ if not base:
239
+ return endpoint
240
+ if endpoint.startswith("/"):
241
+ return f"{base}{endpoint}"
242
+ return f"{base}/{endpoint}"
243
+
244
+ def _resolve_base_url_for_service(self, service: str) -> str:
245
+ """Per-service base URL (e.g. backend API on :8101, frontend proxy on :8100)."""
246
+ overrides = getattr(self.config.testql, "service_base_urls", None) or {}
247
+ if isinstance(overrides, dict):
248
+ override = (overrides.get(service) or "").strip().rstrip("/")
249
+ if override:
250
+ return override
251
+ if service.lower() in {"backend", "api"}:
252
+ api_base = (getattr(self.config.testql, "api_base_url", None) or "").strip().rstrip("/")
253
+ if api_base:
254
+ return api_base
255
+ return self._resolve_base_url()
256
+
202
257
  def _resolve_base_url(self) -> str:
203
258
  base_url = (self.config.testql.base_url or "").strip()
204
259
  if base_url:
@@ -253,6 +308,8 @@ class TestQLWatcher(WupWatcher):
253
308
  score += 2
254
309
  if "infra" in name or "smoke" in name:
255
310
  score += 1
311
+ if "generated-api-smoke" in name:
312
+ score -= 5
256
313
  return score
257
314
 
258
315
  def _select_scenarios_for_service(self, service: str) -> List[Path]:
@@ -260,20 +317,28 @@ class TestQLWatcher(WupWatcher):
260
317
  if not all_scenarios:
261
318
  return []
262
319
 
320
+ svc_config = self.get_service_config(service)
321
+ limit = (svc_config.quick_tests.max_endpoints
322
+ if svc_config and svc_config.quick_tests else self.quick_limit)
323
+
263
324
  tokens = self._tokenize_service(service)
264
325
  scored = sorted(
265
326
  ((self._score_scenario(s, tokens), s) for s in all_scenarios),
266
327
  key=lambda item: (item[0], item[1].name),
267
328
  reverse=True,
268
329
  )
269
- selected = [s for score, s in scored if score > 0]
330
+ selected = [s for score, s in scored if score > 0][:limit]
270
331
  if selected:
271
332
  return selected
272
333
 
273
- svc_config = self.get_service_config(service)
274
- limit = (svc_config.quick_tests.max_endpoints
275
- if svc_config and svc_config.quick_tests else self.quick_limit)
276
- return all_scenarios if limit >= len(all_scenarios) else all_scenarios[:limit]
334
+ smoke_name = (self.config.testql.smoke_scenario or "").strip()
335
+ if smoke_name:
336
+ for base in (self.scenarios_dir, self.project_root):
337
+ candidate = base / smoke_name
338
+ if candidate.exists():
339
+ return [candidate]
340
+
341
+ return []
277
342
 
278
343
  def _run_testql(self, args: Sequence[str], timeout: int) -> subprocess.CompletedProcess:
279
344
  cmd = [self.testql_bin, *args]
@@ -396,7 +461,8 @@ class TestQLWatcher(WupWatcher):
396
461
  if not self.monitor:
397
462
  return True
398
463
 
399
- probes = self.monitor.probes_for_service(service, merged_endpoints)
464
+ # Health probes come only from wup.yaml + TestQL discovery — not deps.json page routes.
465
+ probes = self.monitor.probes_for_service(service)
400
466
  if not probes:
401
467
  return True
402
468
 
@@ -427,6 +493,36 @@ class TestQLWatcher(WupWatcher):
427
493
  )
428
494
  return False
429
495
 
496
+ @staticmethod
497
+ def _summarize_health_scenario_failure(result: subprocess.CompletedProcess) -> str:
498
+ """Extract a short human summary from TestQL --output json (avoid trailing '}')."""
499
+ blob = "\n".join(part for part in (result.stdout or "", result.stderr or "") if part).strip()
500
+ if not blob:
501
+ return "health_scenario failed"
502
+
503
+ start = blob.rfind("{")
504
+ if start >= 0:
505
+ try:
506
+ data = json.loads(blob[start:])
507
+ except json.JSONDecodeError:
508
+ data = None
509
+ if isinstance(data, dict):
510
+ passed = data.get("passed")
511
+ failed = data.get("failed")
512
+ if isinstance(passed, int) and isinstance(failed, int):
513
+ total = passed + failed
514
+ errors = data.get("errors") or []
515
+ hint = f" — {errors[0]}" if errors else ""
516
+ return f"{passed}/{total} passed, {failed} failed{hint}"
517
+
518
+ for line in reversed(blob.splitlines()):
519
+ stripped = line.strip()
520
+ if not stripped or stripped in {"}", "{"}:
521
+ continue
522
+ if "passed" in stripped.lower() or "failed" in stripped.lower() or "❌" in stripped:
523
+ return stripped
524
+ return "health_scenario failed"
525
+
430
526
  async def _run_fleet_health_scenario(self) -> bool:
431
527
  """Optional full TestQL run (not dry-run) for fleet-wide health scenarios."""
432
528
  scenario_name = (self.config.testql.health_scenario or "").strip()
@@ -435,9 +531,12 @@ class TestQLWatcher(WupWatcher):
435
531
 
436
532
  scenario_path = Path(scenario_name)
437
533
  if not scenario_path.is_absolute():
438
- scenario_path = self.project_root / scenario_name
439
- if not scenario_path.exists():
440
- scenario_path = self.scenarios_dir / scenario_name
534
+ candidates = [
535
+ self.scenarios_dir / scenario_name,
536
+ self.project_root / scenario_name,
537
+ self.project_root / self.config.testql.scenario_dir / scenario_name,
538
+ ]
539
+ scenario_path = next((p for p in candidates if p.exists()), candidates[0])
441
540
  if not scenario_path.exists():
442
541
  self.console.print(f"[yellow]⚠ health_scenario not found: {scenario_name}[/yellow]")
443
542
  return True
@@ -454,17 +553,26 @@ class TestQLWatcher(WupWatcher):
454
553
  )
455
554
  return True
456
555
 
457
- reason = result.stderr.strip() or result.stdout.strip() or "health_scenario failed"
556
+ summary = self._summarize_health_scenario_failure(result)
458
557
  track_path = self._write_track(service=fleet, stage="health_scenario", scenario=scenario_path, result=result)
558
+ strict = bool(getattr(self.config.testql, "health_scenario_strict", False))
559
+ # Non-strict: informational only — must not mark fleet "down" (koru reads service-health.json).
560
+ fleet_status = "down" if strict else "degraded"
459
561
  self._record_health_transition(
460
562
  service=fleet,
461
- status="down",
563
+ status=fleet_status,
462
564
  stage="health_scenario",
463
- message=reason,
565
+ message=summary[:500],
464
566
  track_file=str(track_path),
465
567
  )
466
- self.console.print(f"[red]✗ Fleet health scenario failed: {reason}[/red]")
467
- return False
568
+ if strict:
569
+ self.console.print(f"[red]✗ Fleet health scenario failed: {summary}[/red]")
570
+ return False
571
+ self.console.print(
572
+ f"[yellow]⚠ Fleet health scenario incomplete: {summary} "
573
+ f"(per-service probes continue; set testql.health_scenario_strict: true to hard-fail)[/yellow]"
574
+ )
575
+ return True
468
576
 
469
577
  async def run_quick_test(self, service: str, endpoints: List[str]) -> bool:
470
578
  merged_endpoints = self._merge_endpoints(service, endpoints)
@@ -35,6 +35,7 @@ console = Console()
35
35
  # ---------------------------------------------------------------------------
36
36
 
37
37
  _PW_AVAILABLE: Optional[bool] = None
38
+ _PW_WARNED: bool = False
38
39
 
39
40
 
40
41
  def _playwright_available() -> bool:
@@ -48,6 +49,17 @@ def _playwright_available() -> bool:
48
49
  return _PW_AVAILABLE
49
50
 
50
51
 
52
+ def _warn_playwright_missing() -> None:
53
+ global _PW_WARNED
54
+ if _PW_WARNED:
55
+ return
56
+ _PW_WARNED = True
57
+ console.print(
58
+ "[yellow]visual_diff: playwright not installed — DOM scan skipped "
59
+ "(pip install playwright && playwright install chromium)[/yellow]"
60
+ )
61
+
62
+
51
63
  _DOM_SNAPSHOT_JS = """
52
64
  (maxDepth) => {
53
65
  function snapshot(node, depth) {
@@ -85,7 +97,7 @@ async def _fetch_dom_snapshot(
85
97
  ) -> Optional[Dict]:
86
98
  """Return a DOM structure dict for *url* using Playwright."""
87
99
  if not _playwright_available():
88
- console.print("[yellow]visual_diff: playwright not installed — skipping DOM scan[/yellow]")
100
+ _warn_playwright_missing()
89
101
  return None
90
102
  try:
91
103
  from playwright.async_api import async_playwright
@@ -273,7 +285,8 @@ class VisualDiffer:
273
285
  pages: List[str] = list(self.cfg.pages)
274
286
 
275
287
  if self.cfg.pages_from_endpoints and endpoints:
276
- pages.extend(endpoints)
288
+ for endpoint in endpoints:
289
+ pages.append(endpoint)
277
290
 
278
291
  if not pages:
279
292
  pages = [f"/{service}"]
@@ -297,10 +310,17 @@ class VisualDiffer:
297
310
  if not self.cfg.enabled:
298
311
  return []
299
312
 
313
+ if not _playwright_available():
314
+ _warn_playwright_missing()
315
+ return []
316
+
300
317
  if self.cfg.delay_seconds > 0:
301
318
  await asyncio.sleep(self.cfg.delay_seconds)
302
319
 
303
320
  pages = self._pages_for_service(service, endpoints)
321
+ max_pages = max(1, int(self.cfg.max_pages or 5))
322
+ if len(pages) > max_pages:
323
+ pages = pages[:max_pages]
304
324
  results = []
305
325
  for url in pages:
306
326
  result = await self._check_page(service, url)
@@ -321,7 +341,10 @@ class VisualDiffer:
321
341
  )
322
342
  elif result["diff"]["status"] == "new":
323
343
  console.print(f"[dim]📷 Baseline snapshot: {url}[/dim]")
324
- else:
344
+ elif result["diff"]["status"] == "error":
345
+ message = result["diff"].get("message", "scan failed")
346
+ console.print(f"[yellow]⚠ Visual diff skipped: {url} ({message})[/yellow]")
347
+ elif result["diff"]["status"] == "ok":
325
348
  console.print(f"[dim green]✓ No DOM change: {url}[/dim green]")
326
349
 
327
350
  return results
@@ -26,6 +26,7 @@ from .models.config import WebConfig
26
26
  _console = Console()
27
27
  _HTTPX_AVAILABLE: Optional[bool] = None
28
28
  _HTTPX_WARN_LOGGED: bool = False
29
+ _SEND_FAIL_WARNED: bool = False
29
30
 
30
31
 
31
32
  def _httpx_available() -> bool:
@@ -122,7 +123,13 @@ class WebClient:
122
123
  )
123
124
  return False
124
125
  except Exception as exc: # noqa: BLE001 — soft-fail by design
125
- _console.print(f"[yellow]wup.web_client: send_event failed ({exc})[/yellow]")
126
+ global _SEND_FAIL_WARNED
127
+ if not _SEND_FAIL_WARNED:
128
+ _SEND_FAIL_WARNED = True
129
+ _console.print(
130
+ f"[yellow]wup.web_client: send_event failed ({exc}) — "
131
+ f"set web.enabled=false or start wupbro at {self.endpoint}[/yellow]"
132
+ )
126
133
  return False
127
134
 
128
135
  async def send_regression(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wup
3
- Version: 0.2.26
3
+ Version: 0.2.28
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
@@ -29,17 +29,17 @@ Dynamic: license-file
29
29
 
30
30
  ## AI Cost Tracking
31
31
 
32
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.26-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
33
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$1.95-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-15.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
32
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.28-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
33
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.27-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-16.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
34
34
 
35
- - 🤖 **LLM usage:** $1.9540 (36 commits)
36
- - 👤 **Human dev:** ~$1532 (15.3h @ $100/h, 30min dedup)
35
+ - 🤖 **LLM usage:** $2.2653 (38 commits)
36
+ - 👤 **Human dev:** ~$1632 (16.3h @ $100/h, 30min dedup)
37
37
 
38
- Generated on 2026-05-16 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
38
+ Generated on 2026-05-17 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
39
39
 
40
40
  ---
41
41
 
42
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.26-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
42
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.28-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
43
43
 
44
44
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
45
45
 
@@ -83,7 +83,7 @@ wup assistant --quick --template fastapi
83
83
  # 3. Build dependency map (one-time setup)
84
84
  wup map-deps ./my-project
85
85
 
86
- # 4. Start watching for changes
86
+ # 4. Start watching (TestQL + live probes every 60s by default)
87
87
  wup watch ./my-project
88
88
 
89
89
  # 5. Start with live dashboard
@@ -110,14 +110,18 @@ wup map-deps ./my-project --output my-deps.json
110
110
  ### Watch Project
111
111
 
112
112
  ```bash
113
- # Basic watching (uses wup.yaml if present)
113
+ # Basic watching: TestQL mode + live HTTP probes every 60s (uses wup.yaml if present)
114
114
  wup watch ./my-project
115
115
 
116
+ # Legacy HTTP-only watcher (no TestQL, no periodic probes unless configured)
117
+ wup watch ./my-project --mode default --probe-interval 0
118
+
116
119
  # With custom settings
117
120
  wup watch ./my-project \
118
121
  --cpu-throttle 0.5 \
119
122
  --debounce 3 \
120
- --cooldown 600
123
+ --cooldown 600 \
124
+ --probe-interval 120
121
125
 
122
126
  # With live dashboard
123
127
  wup watch ./my-project --dashboard
@@ -125,9 +129,6 @@ wup watch ./my-project --dashboard
125
129
  # Use specific config file
126
130
  wup watch ./my-project --config custom-config.yaml
127
131
 
128
- # TestQL mode
129
- wup watch ./my-project --mode testql
130
-
131
132
  # Discover endpoints from TestQL scenarios
132
133
  wup testql-endpoints /path/to/scenarios --output testql-deps.json
133
134
  ```
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