wup 0.2.64__tar.gz → 0.2.65__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 (60) hide show
  1. {wup-0.2.64/wup.egg-info → wup-0.2.65}/PKG-INFO +7 -7
  2. {wup-0.2.64 → wup-0.2.65}/README.md +6 -6
  3. {wup-0.2.64 → wup-0.2.65}/pyproject.toml +1 -1
  4. {wup-0.2.64 → wup-0.2.65}/tests/test_testql_monitor.py +53 -6
  5. {wup-0.2.64 → wup-0.2.65}/tests/test_wup.py +31 -0
  6. {wup-0.2.64 → wup-0.2.65}/wup/__init__.py +1 -1
  7. {wup-0.2.64 → wup-0.2.65}/wup/config.py +11 -5
  8. {wup-0.2.64 → wup-0.2.65}/wup/models/config.py +3 -0
  9. {wup-0.2.64 → wup-0.2.65}/wup/monitoring_manifest.py +30 -5
  10. {wup-0.2.64 → wup-0.2.65}/wup/testing/handlers/health_handlers.py +11 -7
  11. {wup-0.2.64 → wup-0.2.65}/wup/testql_monitor.py +197 -32
  12. {wup-0.2.64 → wup-0.2.65}/wup/testql_watcher.py +51 -2
  13. {wup-0.2.64 → wup-0.2.65/wup.egg-info}/PKG-INFO +7 -7
  14. {wup-0.2.64 → wup-0.2.65}/LICENSE +0 -0
  15. {wup-0.2.64 → wup-0.2.65}/setup.cfg +0 -0
  16. {wup-0.2.64 → wup-0.2.65}/tests/test_assistant.py +0 -0
  17. {wup-0.2.64 → wup-0.2.65}/tests/test_auto_detection.py +0 -0
  18. {wup-0.2.64 → wup-0.2.65}/tests/test_cli_filtering.py +0 -0
  19. {wup-0.2.64 → wup-0.2.65}/tests/test_e2e.py +0 -0
  20. {wup-0.2.64 → wup-0.2.65}/tests/test_health_summary_passed.py +0 -0
  21. {wup-0.2.64 → wup-0.2.65}/tests/test_monitoring_manifest.py +0 -0
  22. {wup-0.2.64 → wup-0.2.65}/tests/test_probe_mutex.py +0 -0
  23. {wup-0.2.64 → wup-0.2.65}/tests/test_service_inference.py +0 -0
  24. {wup-0.2.64 → wup-0.2.65}/tests/test_testql_watcher.py +0 -0
  25. {wup-0.2.64 → wup-0.2.65}/tests/test_visual_diff_periodic_skip.py +0 -0
  26. {wup-0.2.64 → wup-0.2.65}/tests/test_visual_diff_progress.py +0 -0
  27. {wup-0.2.64 → wup-0.2.65}/tests/test_watch_exclude.py +0 -0
  28. {wup-0.2.64 → wup-0.2.65}/tests/test_web_client.py +0 -0
  29. {wup-0.2.64 → wup-0.2.65}/wup/_ast_detector.py +0 -0
  30. {wup-0.2.64 → wup-0.2.65}/wup/_base_detector.py +0 -0
  31. {wup-0.2.64 → wup-0.2.65}/wup/_hash_detector.py +0 -0
  32. {wup-0.2.64 → wup-0.2.65}/wup/_yaml_detector.py +0 -0
  33. {wup-0.2.64 → wup-0.2.65}/wup/anomaly_detector.py +0 -0
  34. {wup-0.2.64 → wup-0.2.65}/wup/anomaly_models.py +0 -0
  35. {wup-0.2.64 → wup-0.2.65}/wup/assistant.py +0 -0
  36. {wup-0.2.64 → wup-0.2.65}/wup/assistant_discovery.py +0 -0
  37. {wup-0.2.64 → wup-0.2.65}/wup/assistant_validator.py +0 -0
  38. {wup-0.2.64 → wup-0.2.65}/wup/bus.py +0 -0
  39. {wup-0.2.64 → wup-0.2.65}/wup/cli.py +0 -0
  40. {wup-0.2.64 → wup-0.2.65}/wup/cli_config_generator.py +0 -0
  41. {wup-0.2.64 → wup-0.2.65}/wup/cli_scanner.py +0 -0
  42. {wup-0.2.64 → wup-0.2.65}/wup/core.py +0 -0
  43. {wup-0.2.64 → wup-0.2.65}/wup/dependency_mapper.py +0 -0
  44. {wup-0.2.64 → wup-0.2.65}/wup/event_store.py +0 -0
  45. {wup-0.2.64 → wup-0.2.65}/wup/file_watcher/events/file_events.py +0 -0
  46. {wup-0.2.64 → wup-0.2.65}/wup/models/__init__.py +0 -0
  47. {wup-0.2.64 → wup-0.2.65}/wup/planfile_reporter.py +0 -0
  48. {wup-0.2.64 → wup-0.2.65}/wup/testing/events/health_events.py +0 -0
  49. {wup-0.2.64 → wup-0.2.65}/wup/testing/events/test_results.py +0 -0
  50. {wup-0.2.64 → wup-0.2.65}/wup/testing/handlers/event_handlers.py +0 -0
  51. {wup-0.2.64 → wup-0.2.65}/wup/testing/queries/health_queries.py +0 -0
  52. {wup-0.2.64 → wup-0.2.65}/wup/testql_cli_generator.py +0 -0
  53. {wup-0.2.64 → wup-0.2.65}/wup/testql_discovery.py +0 -0
  54. {wup-0.2.64 → wup-0.2.65}/wup/visual_diff.py +0 -0
  55. {wup-0.2.64 → wup-0.2.65}/wup/web_client.py +0 -0
  56. {wup-0.2.64 → wup-0.2.65}/wup.egg-info/SOURCES.txt +0 -0
  57. {wup-0.2.64 → wup-0.2.65}/wup.egg-info/dependency_links.txt +0 -0
  58. {wup-0.2.64 → wup-0.2.65}/wup.egg-info/entry_points.txt +0 -0
  59. {wup-0.2.64 → wup-0.2.65}/wup.egg-info/requires.txt +0 -0
  60. {wup-0.2.64 → wup-0.2.65}/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.64
3
+ Version: 0.2.65
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
@@ -31,17 +31,17 @@ Dynamic: license-file
31
31
 
32
32
  ## AI Cost Tracking
33
33
 
34
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.64-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$3.49-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-31.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
34
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.65-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$3.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-33.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
36
36
 
37
- - 🤖 **LLM usage:** $3.4940 (76 commits)
38
- - 👤 **Human dev:** ~$3168 (31.7h @ $100/h, 30min dedup)
37
+ - 🤖 **LLM usage:** $3.4955 (77 commits)
38
+ - 👤 **Human dev:** ~$3318 (33.2h @ $100/h, 30min dedup)
39
39
 
40
- Generated on 2026-05-27 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
40
+ Generated on 2026-06-03 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
41
41
 
42
42
  ---
43
43
 
44
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.64-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
44
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.65-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
45
45
 
46
46
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
47
47
 
@@ -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.64-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.49-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-31.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.65-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.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-33.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $3.4940 (76 commits)
10
- - 👤 **Human dev:** ~$3168 (31.7h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $3.4955 (77 commits)
10
+ - 👤 **Human dev:** ~$3318 (33.2h @ $100/h, 30min dedup)
11
11
 
12
- Generated on 2026-05-27 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
12
+ Generated on 2026-06-03 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.64-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.65-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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wup"
7
- version = "0.2.64"
7
+ version = "0.2.65"
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,11 +34,48 @@ 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():
37
+ def test_hardware_identify_and_peripheral_status_are_live_probes():
38
+ assert is_monitoring_probe(
39
+ ProbeTarget(url="http://localhost:8202/api/v1/hardware/identify")
40
+ )
41
+ assert is_monitoring_probe(
42
+ ProbeTarget(url="http://localhost:8096/api/v3/hardware/peripheral-status/modbus-io")
43
+ )
44
+
45
+
46
+ def test_firmware_plugin_health_catalog_not_periodic_live_probe():
47
+ """Plugin health is listed for detail TestQL; live watch uses identify + peripheral-status."""
38
48
  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)
49
+ assert is_monitoring_probe(probe)
50
+ with tempfile.TemporaryDirectory() as tmpdir:
51
+ root = Path(tmpdir)
52
+ cfg = WupConfig(
53
+ project=ProjectConfig(name="demo"),
54
+ services=[
55
+ ServiceConfig(name="firmware", paths=["backend/firmware/**"]),
56
+ ServiceConfig(name="connect-scenario", paths=["connect-scenario/**"]),
57
+ ],
58
+ watch=WatchConfig(),
59
+ testql=TestQLConfig(
60
+ hardware_usb_modules={
61
+ "oqlos_url": "http://localhost:8202",
62
+ "proxy_url": "http://localhost:8096",
63
+ "module_ids": ["modbus-io", "modbus-adc"],
64
+ },
65
+ ),
66
+ )
67
+ monitor = TestQLMonitor(root, cfg)
68
+ firmware = {p.url for p in monitor.probes_for_service("firmware")}
69
+ scenario = {p.url for p in monitor.probes_for_service("connect-scenario")}
70
+ assert "http://localhost:8202/api/v1/hardware/identify" in firmware
71
+ assert "http://localhost:8202/api/v1/plugins/modbus-io/health" not in firmware
72
+ assert "http://localhost:8096/api/v3/hardware/identify" in scenario
73
+ assert (
74
+ "http://localhost:8096/api/v3/hardware/peripheral-status/modbus-io" in scenario
75
+ )
76
+ assert (
77
+ "http://localhost:8096/api/v3/hardware/peripheral-status/modbus-adc" in scenario
78
+ )
42
79
 
43
80
 
44
81
  def test_connect_api_paths_on_8100_are_not_monitoring_probes():
@@ -108,8 +145,18 @@ def test_monitor_merges_config_and_service_map():
108
145
  probes = monitor.probes_for_service("firmware")
109
146
  urls = {p.url for p in probes}
110
147
  assert "http://localhost:8100/firmware/api/v1/health" in urls
111
- assert "http://localhost:8100/firmware/api/v1/execution/status" in urls
112
- assert "http://localhost:8100/firmware/api/v1/execution/logs" in urls
148
+ # Execution telemetry is for fleet TestQL, not WUP live liveness probes.
149
+ assert "http://localhost:8100/firmware/api/v1/execution/status" not in urls
150
+ assert "http://localhost:8100/firmware/api/v1/execution/logs" not in urls
151
+
152
+
153
+ def test_firmware_live_probe_prefers_oqlos_8202():
154
+ probes = [
155
+ ProbeTarget(url="http://localhost:8100/firmware/api/v1/health"),
156
+ ProbeTarget(url="http://localhost:8202/health"),
157
+ ]
158
+ ordered = TestQLMonitor._sort_probes_for_live(probes, service="firmware")
159
+ assert ordered[0].url == "http://localhost:8202/health"
113
160
 
114
161
 
115
162
  def test_probes_for_service_ignores_non_health_extra_paths():
@@ -1790,6 +1790,37 @@ class TestTestQLWatcherConfig:
1790
1790
  scenarios = watcher._select_scenarios_for_service("users")
1791
1791
  # Should be limited by config when no matching scenarios found
1792
1792
  assert len(scenarios) <= 2
1793
+
1794
+ def test_testql_watcher_select_scenarios_uses_pinned_scenario(self):
1795
+ """Pinned quick_tests.scenario wins over auto-api scoring."""
1796
+ with tempfile.TemporaryDirectory() as tmpdir:
1797
+ scenarios_dir = Path(tmpdir) / "testql-scenarios"
1798
+ scenarios_dir.mkdir()
1799
+ (scenarios_dir / "auto-api-connect-workshop.testql.toon.yaml").write_text("# workshop")
1800
+ pinned = scenarios_dir / "connect-scenario-wup-quick-fast.testql.toon.yaml"
1801
+ pinned.write_text("# sentinel")
1802
+
1803
+ service = ServiceConfig(
1804
+ name="connect-scenario",
1805
+ type="web",
1806
+ paths=["connect-scenario/**"],
1807
+ quick_tests=ServiceTestConfig(
1808
+ scope="all",
1809
+ max_endpoints=1,
1810
+ scenario="connect-scenario-wup-quick-fast.testql.toon.yaml",
1811
+ ),
1812
+ )
1813
+ config = WupConfig(
1814
+ project=ProjectConfig(name="test"),
1815
+ watch=WatchConfig(),
1816
+ services=[service],
1817
+ test_strategy=TestStrategyConfig(),
1818
+ testql=TestQLConfig(),
1819
+ )
1820
+
1821
+ watcher = TestQLWatcher(tmpdir, scenarios_dir="testql-scenarios", config=config)
1822
+ selected = watcher._select_scenarios_for_service("connect-scenario")
1823
+ assert selected == [pinned]
1793
1824
 
1794
1825
  def test_testql_watcher_uses_config_timeout(self):
1795
1826
  """Test that TestQLWatcher uses config timeout settings."""
@@ -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.64"
10
+ __version__ = "0.2.65"
11
11
  __author__ = "Tom Sapletta"
12
12
 
13
13
  from .config import load_config, save_config, get_default_config
@@ -139,11 +139,13 @@ def _parse_services_config(raw: dict) -> List[ServiceConfig]:
139
139
  type=svc_raw.get("type", "auto"),
140
140
  quick_tests=ServiceTestConfig(
141
141
  scope=quick_tests_raw.get("scope", "all"),
142
- max_endpoints=quick_tests_raw.get("max_endpoints", 10)
142
+ max_endpoints=quick_tests_raw.get("max_endpoints", 10),
143
+ scenario=quick_tests_raw.get("scenario", ""),
143
144
  ),
144
145
  detail_tests=ServiceTestConfig(
145
146
  scope=detail_tests_raw.get("scope", "all"),
146
- max_endpoints=detail_tests_raw.get("max_endpoints", 10)
147
+ max_endpoints=detail_tests_raw.get("max_endpoints", 10),
148
+ scenario=detail_tests_raw.get("scenario", ""),
147
149
  ),
148
150
  cpu_throttle=svc_raw.get("cpu_throttle", 0.8),
149
151
  notify=NotifyConfig(
@@ -233,7 +235,8 @@ def _parse_testql_config(raw: dict) -> TestQLConfig:
233
235
  base_url_env=testql_raw.get("base_url_env", "WUP_BASE_URL"),
234
236
  service_base_urls=testql_raw.get("service_base_urls", {}),
235
237
  explicit_endpoints=testql_raw.get("explicit_endpoints", []),
236
- endpoints_by_service=testql_raw.get("endpoints_by_service", {})
238
+ endpoints_by_service=testql_raw.get("endpoints_by_service", {}),
239
+ hardware_usb_modules=testql_raw.get("hardware_usb_modules", {}),
237
240
  )
238
241
 
239
242
 
@@ -473,6 +476,7 @@ def save_config(config: WupConfig, output_path: Path):
473
476
  "service_base_urls": config.testql.service_base_urls,
474
477
  "explicit_endpoints": config.testql.explicit_endpoints,
475
478
  "endpoints_by_service": config.testql.endpoints_by_service,
479
+ "hardware_usb_modules": config.testql.hardware_usb_modules,
476
480
  },
477
481
  "visual_diff": {
478
482
  "enabled": config.visual_diff.enabled,
@@ -531,11 +535,13 @@ def save_config(config: WupConfig, output_path: Path):
531
535
  "paths": svc.paths,
532
536
  "quick_tests": {
533
537
  "scope": svc.quick_tests.scope,
534
- "max_endpoints": svc.quick_tests.max_endpoints
538
+ "max_endpoints": svc.quick_tests.max_endpoints,
539
+ **({"scenario": svc.quick_tests.scenario} if svc.quick_tests.scenario else {}),
535
540
  },
536
541
  "detail_tests": {
537
542
  "scope": svc.detail_tests.scope,
538
- "max_endpoints": svc.detail_tests.max_endpoints
543
+ "max_endpoints": svc.detail_tests.max_endpoints,
544
+ **({"scenario": svc.detail_tests.scenario} if svc.detail_tests.scenario else {}),
539
545
  },
540
546
  "cpu_throttle": svc.cpu_throttle,
541
547
  "notify": {
@@ -23,6 +23,7 @@ class ServiceTestConfig:
23
23
  """Test configuration for a service (quick or detail)."""
24
24
  scope: str = "all" # "read", "write", "auth", "all", or comma-separated
25
25
  max_endpoints: int = 10
26
+ scenario: str = "" # Optional pinned scenario path (relative to project root or scenarios_dir)
26
27
 
27
28
 
28
29
  @dataclass
@@ -74,6 +75,8 @@ class TestQLConfig:
74
75
  service_base_urls: Dict[str, str] = field(default_factory=dict) # optional per-service override
75
76
  explicit_endpoints: List[str] = field(default_factory=list)
76
77
  endpoints_by_service: Dict[str, List[str]] = field(default_factory=dict)
78
+ # USB kit modules: expand HTTP API probes from module_ids (no /dev/* paths).
79
+ hardware_usb_modules: Dict[str, object] = field(default_factory=dict)
77
80
 
78
81
 
79
82
  @dataclass
@@ -105,15 +105,22 @@ def discover_docker_compose_services(project_root: Path) -> List[DockerComposeSe
105
105
  return discovered_services
106
106
 
107
107
 
108
+ _BASH_DEFAULT_RE = re.compile(r"\$\{[^}]*:-(\d+)\}")
109
+
110
+
108
111
  def _host_port_from_mapping(mapping: str) -> Optional[int]:
109
- """Extract host port from '8100:8100' or '8100:80'."""
112
+ """Extract host port from '8100:8100', '8100:80', or '${VAR:-8100}:8100'."""
110
113
  text = mapping.strip().strip("'\"")
111
114
  if ":" not in text:
112
115
  return None
113
- host = text.split(":")[0]
116
+ # Split on last colon: ${VAR:-default}:container contains internal colons
117
+ host = text.rsplit(":", 1)[0]
114
118
  try:
115
119
  return int(host)
116
120
  except ValueError:
121
+ m = _BASH_DEFAULT_RE.search(host)
122
+ if m:
123
+ return int(m.group(1))
117
124
  return None
118
125
 
119
126
 
@@ -124,16 +131,34 @@ def _map_docker_to_wup_service(
124
131
  name = docker.compose_service.lower()
125
132
  container = docker.container_name.lower()
126
133
 
127
- rules = [
134
+ def _connect_backend_target() -> Optional[str]:
135
+ if not (name.endswith("-backend") and "connect" in name):
136
+ return None
137
+ specific = name.replace("-backend", "")
138
+ if specific in wup_services:
139
+ return specific
140
+ if "backend" in wup_services:
141
+ return "backend"
142
+ return None
143
+
144
+ rules: list[tuple[Any, Any]] = [
128
145
  (lambda: "firmware" in name or "firmware" in container, "firmware"),
129
146
  (lambda: name == "frontend" or "frontend" in container, "frontend"),
130
147
  (lambda: name == "backend" or container == "identification-backend", "backend"),
131
- (lambda: name.endswith("-backend") and "connect" in name, name.replace("-backend", "")),
148
+ (lambda: "connect-scenario" in name and "backend" not in name and "backend" in wup_services, "backend"),
132
149
  ]
133
150
  for predicate, target in rules:
134
- if predicate() and target in wup_services:
151
+ if callable(target):
152
+ resolved = target()
153
+ if predicate() and resolved and resolved in wup_services:
154
+ return resolved
155
+ elif predicate() and target in wup_services:
135
156
  return target
136
157
 
158
+ connect_target = _connect_backend_target()
159
+ if connect_target:
160
+ return connect_target
161
+
137
162
  for svc in wup_services:
138
163
  token = svc.lower().replace("_", "-")
139
164
  if token in name or token in container:
@@ -57,13 +57,17 @@ class ServiceHealthProjection:
57
57
 
58
58
  # Notify external systems
59
59
  if event.status in {"down", "degraded"}:
60
- self.planfile_reporter.report_failure(
61
- service=event.service,
62
- status=event.status,
63
- stage=event.stage,
64
- message=event.message,
65
- track_file=event.track_file,
66
- )
60
+ # Non-strict fleet health uses status=degraded for optional probe gaps; do not spam planfile.
61
+ if event.stage == "health_scenario" and event.status == "degraded":
62
+ pass
63
+ else:
64
+ self.planfile_reporter.report_failure(
65
+ service=event.service,
66
+ status=event.status,
67
+ stage=event.stage,
68
+ message=event.message,
69
+ track_file=event.track_file,
70
+ )
67
71
  elif event.status == "up":
68
72
  self.planfile_reporter.clear_service_stage(
69
73
  service=event.service, stage=event.stage
@@ -18,8 +18,12 @@ _API_LINE = re.compile(
18
18
  r"^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*,\s*([^\s,]+)(?:\s*,\s*(\d+))?",
19
19
  re.MULTILINE,
20
20
  )
21
+ _SHELL_CURL_URL = re.compile(
22
+ r"""^\s*SHELL\s+["']curl\b[^"']*\s(https?://[^\s"']+)""",
23
+ re.MULTILINE,
24
+ )
21
25
  _HEALTH_HINT = re.compile(
22
- r"(/health|/healthz|/ready|/live|/status|/openapi\.json|/execution/status|/execution/logs)",
26
+ r"(/health|/healthz|/ready|/live|/status|/openapi\.json)",
23
27
  re.IGNORECASE,
24
28
  )
25
29
  # Connect module APIs live on :8103+ — not valid health probes on frontend proxy :8100
@@ -76,13 +80,21 @@ def _parse_api_lines(content: str, source: str) -> List[ProbeTarget]:
76
80
  return probes
77
81
 
78
82
 
83
+ def _parse_shell_curl_lines(content: str, source: str) -> List[ProbeTarget]:
84
+ probes: List[ProbeTarget] = []
85
+ for url in _SHELL_CURL_URL.findall(content):
86
+ url = url.strip().rstrip('"\'')
87
+ probes.append(ProbeTarget(url=url, method="GET", expected_status=200, source=source))
88
+ return probes
89
+
90
+
79
91
  def parse_scenario_probes(scenario_path: Path) -> List[ProbeTarget]:
80
92
  """Extract API probe rows from a TestQL TOON scenario file."""
81
93
  try:
82
94
  content = scenario_path.read_text(encoding="utf-8")
83
95
  except OSError:
84
96
  return []
85
- return _parse_api_lines(content, source=str(scenario_path))
97
+ return _parse_api_lines(content, source=str(scenario_path)) + _parse_shell_curl_lines(content, source=str(scenario_path))
86
98
 
87
99
 
88
100
  def _extract_base_url(data: Dict[str, Any]) -> str:
@@ -137,23 +149,10 @@ def _connect_module_api_on_frontend_proxy(probe: ProbeTarget) -> bool:
137
149
  return any(path.startswith(prefix) for prefix in _CONNECT_API_PREFIXES)
138
150
 
139
151
 
140
- def _firmware_plugin_probe_without_runtime(probe: ProbeTarget) -> bool:
141
- """Plugin health on :8202 requires loaded plugins — skip for bare simulator live probes."""
142
- if not probe.url.startswith("http"):
143
- return False
144
- parsed = urlparse(probe.url)
145
- if parsed.port != 8202:
146
- return False
147
- path = (parsed.path or "").lower()
148
- return "/api/v1/plugins/" in path and path.endswith("/health")
149
-
150
-
151
152
  def is_monitoring_probe(probe: ProbeTarget) -> bool:
152
153
  """True when this endpoint should be used for live service health checks."""
153
154
  if _connect_module_api_on_frontend_proxy(probe):
154
155
  return False
155
- if _firmware_plugin_probe_without_runtime(probe):
156
- return False
157
156
  if probe.url.startswith("http"):
158
157
  path = urlparse(probe.url).path or probe.url
159
158
  else:
@@ -161,6 +160,13 @@ def is_monitoring_probe(probe: ProbeTarget) -> bool:
161
160
  path_lower = path.lower()
162
161
  if any(path_lower.startswith(prefix) for prefix in _CONNECT_API_PREFIXES):
163
162
  return False
163
+ if "/execution/" in path_lower:
164
+ return False
165
+ # Hardware USB kit: enumerate adapters via OqlOS/proxy HTTP (not serial port paths).
166
+ if "/hardware/identify" in path_lower:
167
+ return True
168
+ if "/peripheral-status/" in path_lower:
169
+ return True
164
170
  if _HEALTH_HINT.search(path):
165
171
  return True
166
172
  # Short GET smoke paths (/, /health) without heavy write APIs
@@ -228,7 +234,10 @@ def _assign_by_connect_backend(
228
234
 
229
235
 
230
236
  def _assign_http_probe(
231
- probe: ProbeTarget, services: Sequence[ServiceConfig], path_lower: str
237
+ probe: ProbeTarget,
238
+ services: Sequence[ServiceConfig],
239
+ path_lower: str,
240
+ port_map: Optional[Dict[int, str]] = None,
232
241
  ) -> Optional[str]:
233
242
  """Map an HTTP probe to a service based on port and path."""
234
243
  parsed = urlparse(probe.url)
@@ -240,7 +249,16 @@ def _assign_http_probe(
240
249
  return _assign_by_port_8202(services)
241
250
  if port == 8100:
242
251
  return _assign_by_port_8100(services, path_lower)
243
-
252
+
253
+ if port_map and port in port_map:
254
+ preferred = port_map[port]
255
+ result = _find_service_by_name(services, preferred)
256
+ if result:
257
+ return result
258
+ if preferred == "backend":
259
+ return _find_service_by_name(services, "backend")
260
+ return None
261
+
244
262
  return _assign_by_connect_backend(services, path_lower)
245
263
 
246
264
 
@@ -283,13 +301,17 @@ def _assign_by_path_prefix(
283
301
  return None
284
302
 
285
303
 
286
- def assign_probe_to_service(probe: ProbeTarget, services: Sequence[ServiceConfig]) -> Optional[str]:
304
+ def assign_probe_to_service(
305
+ probe: ProbeTarget,
306
+ services: Sequence[ServiceConfig],
307
+ port_map: Optional[Dict[int, str]] = None,
308
+ ) -> Optional[str]:
287
309
  """Map a probe URL/path to a configured WUP service name."""
288
310
  path = urlparse(probe.url).path if probe.url.startswith("http") else probe.url
289
311
  path_lower = path.lower()
290
312
 
291
313
  if probe.url.startswith("http"):
292
- result = _assign_http_probe(probe, services, path_lower)
314
+ result = _assign_http_probe(probe, services, path_lower, port_map=port_map)
293
315
  if result:
294
316
  return result
295
317
  return None
@@ -333,6 +355,67 @@ class TestQLMonitor:
333
355
  self.scenarios_dir = project_root / (tq.scenario_dir or "testql-scenarios")
334
356
  self.discovery = TestQLEndpointDiscovery(str(self.scenarios_dir))
335
357
 
358
+ def _load_dot_env(self) -> Dict[str, str]:
359
+ """Read key=value pairs from .env in project root (best-effort)."""
360
+ env: Dict[str, str] = {}
361
+ dot_env = self.project_root / ".env"
362
+ if not dot_env.exists():
363
+ return env
364
+ try:
365
+ for line in dot_env.read_text(encoding="utf-8").splitlines():
366
+ line = line.strip()
367
+ if not line or line.startswith("#") or "=" not in line:
368
+ continue
369
+ key, _, value = line.partition("=")
370
+ env[key.strip()] = value.strip().strip("'\"")
371
+ except OSError:
372
+ pass
373
+ return env
374
+
375
+ def _build_port_map(self) -> Dict[int, str]:
376
+ """Build host-port → wup service name map from docker-compose files + .env."""
377
+ from .monitoring_manifest import (
378
+ _map_docker_to_wup_service,
379
+ discover_docker_compose_services,
380
+ )
381
+
382
+ dot_env = self._load_dot_env()
383
+
384
+ def resolve_port(raw: str) -> Optional[int]:
385
+ """Resolve host port from mapping, substituting .env vars before defaults."""
386
+ text = raw.strip().strip("'\"")
387
+ if ":" not in text:
388
+ return None
389
+ # Split on the LAST colon to correctly separate host:container
390
+ # (${VAR:-default} syntax contains colons internally)
391
+ host_part = text.rsplit(":", 1)[0]
392
+ try:
393
+ return int(host_part)
394
+ except ValueError:
395
+ pass
396
+ # Replace ${VAR:-default} using .env then fall back to default
397
+ def _replace(m: "re.Match[str]") -> str:
398
+ inner = m.group(0)[2:-1] # strip ${ and }
399
+ var, _, default = inner.partition(":-")
400
+ return dot_env.get(var, default)
401
+ resolved = re.sub(r"\$\{[^}]+\}", _replace, host_part)
402
+ try:
403
+ return int(resolved)
404
+ except ValueError:
405
+ return None
406
+
407
+ wup_names = [s.name for s in self.config.services]
408
+ port_map: Dict[int, str] = {}
409
+ for docker_svc in discover_docker_compose_services(self.project_root):
410
+ mapped = _map_docker_to_wup_service(docker_svc, wup_names)
411
+ if not mapped:
412
+ continue
413
+ for raw_port in docker_svc.host_ports:
414
+ host_port = resolve_port(raw_port)
415
+ if host_port:
416
+ port_map[host_port] = mapped
417
+ return port_map
418
+
336
419
  def _service_map_paths(self) -> List[Path]:
337
420
  globs = self.config.testql.service_map_globs or []
338
421
  paths: List[Path] = []
@@ -340,11 +423,69 @@ class TestQLMonitor:
340
423
  paths.extend(sorted(self.project_root.glob(pattern)))
341
424
  return paths
342
425
 
426
+ def _add_hardware_usb_module_endpoints(self, accumulator: "_ProbeAccumulator") -> None:
427
+ """Expand hardware_usb_modules from wup.yaml into OqlOS + proxy API probes."""
428
+ raw = getattr(self.config.testql, "hardware_usb_modules", None) or {}
429
+ if not isinstance(raw, dict) or not raw:
430
+ return
431
+ oqlos = str(raw.get("oqlos_url") or "http://localhost:8202").rstrip("/")
432
+ proxy = str(raw.get("proxy_url") or "http://localhost:8096").rstrip("/")
433
+ module_ids = raw.get("module_ids") or []
434
+ if not isinstance(module_ids, list):
435
+ return
436
+
437
+ catalog: List[Tuple[str, ProbeTarget]] = [
438
+ (
439
+ "firmware",
440
+ ProbeTarget(
441
+ url=f"{oqlos}/api/v1/hardware/identify",
442
+ source="wup.yaml:hardware_usb_modules",
443
+ ),
444
+ ),
445
+ (
446
+ "connect-scenario",
447
+ ProbeTarget(
448
+ url=f"{proxy}/api/v3/hardware/identify",
449
+ source="wup.yaml:hardware_usb_modules",
450
+ ),
451
+ ),
452
+ ]
453
+ for module_id in module_ids:
454
+ mid = str(module_id).strip()
455
+ if not mid:
456
+ continue
457
+ catalog.append(
458
+ (
459
+ "firmware",
460
+ ProbeTarget(
461
+ url=f"{oqlos}/api/v1/plugins/{mid}/health",
462
+ source="wup.yaml:hardware_usb_modules:plugin_health",
463
+ ),
464
+ )
465
+ )
466
+ catalog.append(
467
+ (
468
+ "connect-scenario",
469
+ ProbeTarget(
470
+ url=f"{proxy}/api/v3/hardware/peripheral-status/{mid}",
471
+ source="wup.yaml:hardware_usb_modules",
472
+ ),
473
+ )
474
+ )
475
+
476
+ for service, probe in catalog:
477
+ if not is_monitoring_probe(probe):
478
+ continue
479
+ if probe.source.endswith(":plugin_health"):
480
+ continue
481
+ accumulator.add(service, probe)
482
+
343
483
  def _add_config_endpoints(
344
484
  self,
345
485
  accumulator: "_ProbeAccumulator",
346
486
  ) -> None:
347
487
  """Add config-declared endpoints (paths or full URLs) per-service base URL."""
488
+ self._add_hardware_usb_module_endpoints(accumulator)
348
489
  for svc_name, paths in (self.config.testql.endpoints_by_service or {}).items():
349
490
  base = self._resolve_base_url_for_service(svc_name)
350
491
  for path in paths:
@@ -371,26 +512,28 @@ class TestQLMonitor:
371
512
  def _add_scenario_probes(
372
513
  self,
373
514
  accumulator: "_ProbeAccumulator",
515
+ port_map: Optional[Dict[int, str]] = None,
374
516
  ) -> None:
375
517
  """Add TestQL scenario probes mapped to services."""
376
518
  for scenario in self.discovery.discover_scenarios():
377
519
  for probe in parse_scenario_probes(scenario):
378
520
  if not is_monitoring_probe(probe):
379
521
  continue
380
- assigned = assign_probe_to_service(probe, self.config.services)
522
+ assigned = assign_probe_to_service(probe, self.config.services, port_map=port_map)
381
523
  if assigned:
382
524
  accumulator.add(assigned, probe)
383
525
 
384
526
  def _add_service_map_probes(
385
527
  self,
386
528
  accumulator: "_ProbeAccumulator",
529
+ port_map: Optional[Dict[int, str]] = None,
387
530
  ) -> None:
388
531
  """Add service-map TOON/YAML probes mapped to services."""
389
532
  for map_path in self._service_map_paths():
390
533
  for probe in parse_service_map_probes(map_path):
391
534
  if not is_monitoring_probe(probe):
392
535
  continue
393
- assigned = assign_probe_to_service(probe, self.config.services)
536
+ assigned = assign_probe_to_service(probe, self.config.services, port_map=port_map)
394
537
  if assigned:
395
538
  accumulator.add(assigned, probe)
396
539
 
@@ -401,8 +544,9 @@ class TestQLMonitor:
401
544
  self._add_config_endpoints(accumulator)
402
545
 
403
546
  if self.config.testql.endpoint_discovery:
404
- self._add_scenario_probes(accumulator)
405
- self._add_service_map_probes(accumulator)
547
+ port_map = self._build_port_map()
548
+ self._add_scenario_probes(accumulator, port_map=port_map)
549
+ self._add_service_map_probes(accumulator, port_map=port_map)
406
550
 
407
551
  return accumulator.by_service
408
552
 
@@ -449,16 +593,37 @@ class TestQLMonitor:
449
593
  return [p for p in merged if p.url.startswith("http://") or p.url.startswith("https://")]
450
594
 
451
595
  @staticmethod
452
- def _sort_probes_for_live(probes: Sequence[ProbeTarget]) -> List[ProbeTarget]:
453
- """Prefer wup.yaml endpoints before scenario discovery for pass/fail."""
596
+ def _sort_probes_for_live(
597
+ probes: Sequence[ProbeTarget],
598
+ service: str = "",
599
+ ) -> List[ProbeTarget]:
600
+ """Prefer wup.yaml endpoints, then /health URLs, before other scenario probes."""
454
601
 
455
- def rank(probe: ProbeTarget) -> Tuple[int, str]:
602
+ def rank(probe: ProbeTarget) -> Tuple[int, int, int, str]:
456
603
  source = probe.source or ""
457
- if source.startswith("wup.yaml:endpoints_by_service"):
458
- return (0, probe.url)
459
- if source.startswith("wup.yaml:explicit_endpoints"):
460
- return (1, probe.url)
461
- return (2, probe.url)
604
+ path = (urlparse(probe.url).path or probe.url).lower()
605
+ healthish = "/health" in path and "/execution/" not in path
606
+ if source.startswith("wup.yaml:hardware_usb_modules"):
607
+ tier = 0
608
+ elif source.startswith("wup.yaml:endpoints_by_service"):
609
+ tier = 1
610
+ elif source.startswith("wup.yaml:explicit_endpoints"):
611
+ tier = 2
612
+ else:
613
+ tier = 3
614
+ port = urlparse(probe.url).port if probe.url.startswith("http") else None
615
+ # Bench uses host OqlOS on :8202; :8100/firmware proxy often 503 without full make dev.
616
+ port_rank = 0
617
+ if service == "firmware" and port is not None:
618
+ if port == 8202:
619
+ port_rank = 0
620
+ elif port == 8100:
621
+ port_rank = 1
622
+ else:
623
+ port_rank = 2
624
+ health_rank = 0 if healthish else 1
625
+ # Prefer /health liveness before heavier USB identify (tier-0 config).
626
+ return (health_rank, tier, port_rank, probe.url)
462
627
 
463
628
  return sorted(probes, key=rank)
464
629
 
@@ -475,7 +640,7 @@ class TestQLMonitor:
475
640
  return True, ""
476
641
 
477
642
  failed: List[str] = []
478
- ordered = self._sort_probes_for_live(probes)
643
+ ordered = self._sort_probes_for_live(probes, service=service)
479
644
  for probe in ordered[:max_count]:
480
645
  ok, detail = probe.probe(timeout_s=timeout_s)
481
646
  if ok:
@@ -323,6 +323,35 @@ class TestQLWatcher(WupWatcher):
323
323
  return [candidate]
324
324
  return []
325
325
 
326
+ def _resolve_scenario_path(self, scenario: str) -> Optional[Path]:
327
+ """Resolve a pinned scenario path relative to scenarios_dir or project root."""
328
+ name = (scenario or "").strip()
329
+ if not name:
330
+ return None
331
+ path = Path(name)
332
+ if path.is_absolute() and path.exists():
333
+ return path
334
+ for base in (self.scenarios_dir, self.project_root):
335
+ candidate = base / name
336
+ if candidate.exists():
337
+ return candidate
338
+ return None
339
+
340
+ @staticmethod
341
+ def _testql_trailing_json_ok(result: subprocess.CompletedProcess) -> bool:
342
+ """True when TestQL --output json ends with ok:true and zero failed steps."""
343
+ blob = (result.stdout or "").strip()
344
+ start = blob.rfind("{")
345
+ if start < 0:
346
+ return False
347
+ try:
348
+ data = json.loads(blob[start:])
349
+ except json.JSONDecodeError:
350
+ return False
351
+ if not isinstance(data, dict):
352
+ return False
353
+ return data.get("ok") is True and int(data.get("failed", 1)) == 0
354
+
326
355
  @staticmethod
327
356
  def _health_summary_all_passed(summary: str) -> bool:
328
357
  """True when summary looks like N/N passed with zero failures."""
@@ -341,6 +370,12 @@ class TestQLWatcher(WupWatcher):
341
370
  limit = (svc_config.quick_tests.max_endpoints
342
371
  if svc_config and svc_config.quick_tests else self.quick_limit)
343
372
 
373
+ pinned = svc_config.quick_tests.scenario if svc_config and svc_config.quick_tests else ""
374
+ if pinned:
375
+ resolved = self._resolve_scenario_path(pinned)
376
+ if resolved:
377
+ return [resolved][:limit]
378
+
344
379
  # Filter scenarios by service type
345
380
  svc_type = svc_config.type if svc_config else "auto"
346
381
  if getattr(self.config.testql, "quick_smoke_only", False):
@@ -350,6 +385,15 @@ class TestQLWatcher(WupWatcher):
350
385
 
351
386
  filtered_scenarios = self._filter_scenarios_by_type(all_scenarios, svc_type)
352
387
 
388
+ # connect-workshop / auto-api-connect-scenario-* also match token "connect".
389
+ if service == "connect-scenario":
390
+ filtered_scenarios = [
391
+ s
392
+ for s in filtered_scenarios
393
+ if s.name.lower().startswith("connect-scenario-wup-")
394
+ or s.name.lower().startswith("connect-scenario-modbus-")
395
+ ]
396
+
353
397
  selected = self._get_scored_scenarios(filtered_scenarios, self._tokenize_service(service), limit)
354
398
  if selected:
355
399
  return selected
@@ -623,7 +667,8 @@ class TestQLWatcher(WupWatcher):
623
667
  failed = data.get("failed")
624
668
  if not isinstance(passed, int) or not isinstance(failed, int):
625
669
  return None
626
- total = passed + failed
670
+ steps = data.get("steps")
671
+ total = steps if isinstance(steps, int) and steps > 0 else passed + failed
627
672
  errors = data.get("errors") or []
628
673
  hint = f" — {errors[0]}" if errors else ""
629
674
  return f"{passed}/{total} passed, {failed} failed{hint}"
@@ -688,7 +733,11 @@ class TestQLWatcher(WupWatcher):
688
733
  args = ["run", str(scenario_path), "--output", "json", *self.testql_extra_args]
689
734
  result = self._run_testql(args, timeout=max(int(self._quick_probe_timeout()), 120))
690
735
  summary = self._summarize_health_scenario_failure(result)
691
- if result.returncode == 0 or self._health_summary_all_passed(summary):
736
+ if (
737
+ result.returncode == 0
738
+ or self._testql_trailing_json_ok(result)
739
+ or self._health_summary_all_passed(summary)
740
+ ):
692
741
  self._record_health_transition(
693
742
  service=fleet,
694
743
  status="up",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wup
3
- Version: 0.2.64
3
+ Version: 0.2.65
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
@@ -31,17 +31,17 @@ Dynamic: license-file
31
31
 
32
32
  ## AI Cost Tracking
33
33
 
34
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.64-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$3.49-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-31.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
34
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.65-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$3.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-33.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
36
36
 
37
- - 🤖 **LLM usage:** $3.4940 (76 commits)
38
- - 👤 **Human dev:** ~$3168 (31.7h @ $100/h, 30min dedup)
37
+ - 🤖 **LLM usage:** $3.4955 (77 commits)
38
+ - 👤 **Human dev:** ~$3318 (33.2h @ $100/h, 30min dedup)
39
39
 
40
- Generated on 2026-05-27 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
40
+ Generated on 2026-06-03 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
41
41
 
42
42
  ---
43
43
 
44
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.64-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
44
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.65-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
45
45
 
46
46
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
47
47
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes