wup 0.2.9__tar.gz → 0.2.12__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {wup-0.2.9/wup.egg-info → wup-0.2.12}/PKG-INFO +8 -7
- {wup-0.2.9 → wup-0.2.12}/README.md +5 -5
- {wup-0.2.9 → wup-0.2.12}/pyproject.toml +7 -2
- wup-0.2.12/tests/test_testql_watcher.py +201 -0
- {wup-0.2.9 → wup-0.2.12}/wup/__init__.py +1 -1
- {wup-0.2.9 → wup-0.2.12}/wup/cli.py +109 -62
- {wup-0.2.9 → wup-0.2.12}/wup/core.py +9 -1
- {wup-0.2.9 → wup-0.2.12}/wup/testql_watcher.py +95 -6
- {wup-0.2.9 → wup-0.2.12/wup.egg-info}/PKG-INFO +8 -7
- {wup-0.2.9 → wup-0.2.12}/wup.egg-info/requires.txt +1 -0
- wup-0.2.9/tests/test_testql_watcher.py +0 -90
- {wup-0.2.9 → wup-0.2.12}/LICENSE +0 -0
- {wup-0.2.9 → wup-0.2.12}/setup.cfg +0 -0
- {wup-0.2.9 → wup-0.2.12}/tests/test_e2e.py +0 -0
- {wup-0.2.9 → wup-0.2.12}/tests/test_wup.py +0 -0
- {wup-0.2.9 → wup-0.2.12}/wup/config.py +0 -0
- {wup-0.2.9 → wup-0.2.12}/wup/dependency_mapper.py +0 -0
- {wup-0.2.9 → wup-0.2.12}/wup/models/__init__.py +0 -0
- {wup-0.2.9 → wup-0.2.12}/wup/models/config.py +0 -0
- {wup-0.2.9 → wup-0.2.12}/wup/testql_discovery.py +0 -0
- {wup-0.2.9 → wup-0.2.12}/wup.egg-info/SOURCES.txt +0 -0
- {wup-0.2.9 → wup-0.2.12}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.9 → wup-0.2.12}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.9 → wup-0.2.12}/wup.egg-info/top_level.txt +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wup
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.12
|
|
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
|
-
License
|
|
6
|
+
License: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/semcod/wup
|
|
8
8
|
Project-URL: Repository, https://github.com/semcod/wup
|
|
9
9
|
Keywords: wup,watcher,testing,regression,file-monitoring
|
|
@@ -21,6 +21,7 @@ Requires-Dist: watchdog>=4.0.0
|
|
|
21
21
|
Requires-Dist: psutil>=5.9.0
|
|
22
22
|
Requires-Dist: rich>=13.0.0
|
|
23
23
|
Requires-Dist: typer>=0.9.0
|
|
24
|
+
Requires-Dist: pyyaml>=6.0
|
|
24
25
|
Dynamic: license-file
|
|
25
26
|
|
|
26
27
|
# WUP (What's Up)
|
|
@@ -28,17 +29,17 @@ Dynamic: license-file
|
|
|
28
29
|
|
|
29
30
|
## AI Cost Tracking
|
|
30
31
|
|
|
31
|
-
    
|
|
33
|
+
  
|
|
33
34
|
|
|
34
|
-
- 🤖 **LLM usage:** $1.
|
|
35
|
-
- 👤 **Human dev:** ~$
|
|
35
|
+
- 🤖 **LLM usage:** $1.9500 (13 commits)
|
|
36
|
+
- 👤 **Human dev:** ~$312 (3.1h @ $100/h, 30min dedup)
|
|
36
37
|
|
|
37
38
|
Generated on 2026-04-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
38
39
|
|
|
39
40
|
---
|
|
40
41
|
|
|
41
|
-
    
|
|
42
43
|
|
|
43
44
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
44
45
|
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
    
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $1.
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $1.9500 (13 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$312 (3.1h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
12
|
Generated on 2026-04-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
    
|
|
17
17
|
|
|
18
18
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
19
19
|
|
|
@@ -4,11 +4,10 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "wup"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.12"
|
|
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"
|
|
11
|
-
license = "Apache-2.0"
|
|
12
11
|
authors = [
|
|
13
12
|
{name = "Tom Sapletta", email = "tom@sapletta.com"},
|
|
14
13
|
]
|
|
@@ -18,6 +17,7 @@ dependencies = [
|
|
|
18
17
|
"psutil>=5.9.0",
|
|
19
18
|
"rich>=13.0.0",
|
|
20
19
|
"typer>=0.9.0",
|
|
20
|
+
"pyyaml>=6.0",
|
|
21
21
|
]
|
|
22
22
|
classifiers = [
|
|
23
23
|
"Development Status :: 3 - Alpha",
|
|
@@ -29,6 +29,11 @@ classifiers = [
|
|
|
29
29
|
"Programming Language :: Python :: 3.12",
|
|
30
30
|
]
|
|
31
31
|
|
|
32
|
+
[project.license]
|
|
33
|
+
text = "Apache-2.0"
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
include = ["wup*"]
|
|
36
|
+
|
|
32
37
|
[project.scripts]
|
|
33
38
|
wup = "wup.cli:app"
|
|
34
39
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from subprocess import CompletedProcess
|
|
7
|
+
|
|
8
|
+
from wup.testql_watcher import TestQLWatcher
|
|
9
|
+
from wup.models.config import WupConfig, ProjectConfig, TestQLConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_process_changed_file_creates_track_on_failure():
|
|
13
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
14
|
+
root = Path(tmpdir)
|
|
15
|
+
app_file = root / "app" / "users" / "routes.py"
|
|
16
|
+
app_file.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
app_file.write_text("print('x')\n", encoding="utf-8")
|
|
18
|
+
|
|
19
|
+
scenario_dir = root / "testql-scenarios"
|
|
20
|
+
scenario_dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
failing_scenario = scenario_dir / "app-users.testql.toon.yaml"
|
|
22
|
+
failing_scenario.write_text("name: failing\n", encoding="utf-8")
|
|
23
|
+
|
|
24
|
+
# Pass empty config to prevent loading from temp dir
|
|
25
|
+
from wup.models.config import TestQLConfig, WatchConfig
|
|
26
|
+
empty_config = WupConfig(
|
|
27
|
+
project=ProjectConfig(name="test"),
|
|
28
|
+
services=[],
|
|
29
|
+
test_strategy=None,
|
|
30
|
+
watch=WatchConfig(), # Add watch config to avoid file filtering issues
|
|
31
|
+
testql=TestQLConfig(scenario_dir="testql-scenarios")
|
|
32
|
+
)
|
|
33
|
+
watcher = TestQLWatcher(
|
|
34
|
+
project_root=str(root),
|
|
35
|
+
deps_file=str(root / "deps.json"),
|
|
36
|
+
scenarios_dir="testql-scenarios",
|
|
37
|
+
track_dir=".wup/tracks",
|
|
38
|
+
config=empty_config,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
watcher.dependency_mapper.service_to_endpoints["app/users"] = ["/api/v1/users"]
|
|
42
|
+
|
|
43
|
+
def fake_run_testql(args, timeout):
|
|
44
|
+
if "--dry-run" in args:
|
|
45
|
+
return CompletedProcess(args=args, returncode=1, stdout="", stderr="intentional failure")
|
|
46
|
+
return CompletedProcess(args=args, returncode=0, stdout="{}", stderr="")
|
|
47
|
+
|
|
48
|
+
watcher._run_testql = fake_run_testql # type: ignore[method-assign]
|
|
49
|
+
|
|
50
|
+
result = asyncio.run(watcher.process_changed_file_once(str(app_file)))
|
|
51
|
+
|
|
52
|
+
assert result["processed_items"] >= 1
|
|
53
|
+
assert result["last_track_path"] is not None
|
|
54
|
+
|
|
55
|
+
track_path = Path(result["last_track_path"])
|
|
56
|
+
assert track_path.exists()
|
|
57
|
+
|
|
58
|
+
track_payload = json.loads(track_path.read_text(encoding="utf-8"))
|
|
59
|
+
assert track_payload["service"] == "app/users"
|
|
60
|
+
assert track_payload["stage"] == "quick"
|
|
61
|
+
assert "intentional failure" in track_payload["stderr_head"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_browser_event_file_is_written_without_service_url():
|
|
65
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
66
|
+
root = Path(tmpdir)
|
|
67
|
+
scenario_dir = root / "testql-scenarios"
|
|
68
|
+
scenario_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
scenario_file = scenario_dir / "api-users-smoke.testql.toon.yaml"
|
|
70
|
+
scenario_file.write_text("name: smoke\n", encoding="utf-8")
|
|
71
|
+
|
|
72
|
+
watcher = TestQLWatcher(
|
|
73
|
+
project_root=str(root),
|
|
74
|
+
deps_file=str(root / "deps.json"),
|
|
75
|
+
scenarios_dir="testql-scenarios",
|
|
76
|
+
track_dir=".wup/tracks",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
result = CompletedProcess(args=["testql", "run"], returncode=1, stdout="", stderr="boom")
|
|
80
|
+
track_path = watcher._write_track(
|
|
81
|
+
service="app/users",
|
|
82
|
+
stage="quick",
|
|
83
|
+
scenario=scenario_file,
|
|
84
|
+
result=result,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
assert track_path.exists()
|
|
88
|
+
event_file = root / ".wup" / "browser-events" / "latest.json"
|
|
89
|
+
assert event_file.exists()
|
|
90
|
+
event_payload = json.loads(event_file.read_text(encoding="utf-8"))
|
|
91
|
+
assert event_payload["type"] == "wup_testql_error"
|
|
92
|
+
assert event_payload["service"] == "app/users"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_config_endpoints_use_base_url_from_yaml_config():
|
|
96
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
97
|
+
root = Path(tmpdir)
|
|
98
|
+
cfg = WupConfig(
|
|
99
|
+
project=ProjectConfig(name="demo"),
|
|
100
|
+
testql=TestQLConfig(
|
|
101
|
+
base_url="http://localhost:8100",
|
|
102
|
+
explicit_endpoints=["/connect-config"],
|
|
103
|
+
endpoints_by_service={"connect-config": ["/connect-config-sitemap"]},
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
watcher = TestQLWatcher(
|
|
108
|
+
project_root=str(root),
|
|
109
|
+
deps_file=str(root / "deps.json"),
|
|
110
|
+
scenarios_dir="testql-scenarios",
|
|
111
|
+
track_dir=".wup/tracks",
|
|
112
|
+
config=cfg,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
endpoints = watcher._get_config_endpoints_for_service("connect-config")
|
|
116
|
+
assert "http://localhost:8100/connect-config" in endpoints
|
|
117
|
+
assert "http://localhost:8100/connect-config-sitemap" in endpoints
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_config_endpoints_use_base_url_from_env_when_yaml_missing():
|
|
121
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
122
|
+
root = Path(tmpdir)
|
|
123
|
+
cfg = WupConfig(
|
|
124
|
+
project=ProjectConfig(name="demo"),
|
|
125
|
+
testql=TestQLConfig(
|
|
126
|
+
base_url="",
|
|
127
|
+
base_url_env="WUP_BASE_URL",
|
|
128
|
+
explicit_endpoints=["/connect-data"],
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
old_value = os.environ.get("WUP_BASE_URL")
|
|
133
|
+
os.environ["WUP_BASE_URL"] = "http://localhost:8100"
|
|
134
|
+
try:
|
|
135
|
+
watcher = TestQLWatcher(
|
|
136
|
+
project_root=str(root),
|
|
137
|
+
deps_file=str(root / "deps.json"),
|
|
138
|
+
scenarios_dir="testql-scenarios",
|
|
139
|
+
track_dir=".wup/tracks",
|
|
140
|
+
config=cfg,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
endpoints = watcher._get_config_endpoints_for_service("connect-data")
|
|
144
|
+
assert "http://localhost:8100/connect-data" in endpoints
|
|
145
|
+
finally:
|
|
146
|
+
if old_value is None:
|
|
147
|
+
os.environ.pop("WUP_BASE_URL", None)
|
|
148
|
+
else:
|
|
149
|
+
os.environ["WUP_BASE_URL"] = old_value
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_service_health_transitions_are_persisted():
|
|
153
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
154
|
+
root = Path(tmpdir)
|
|
155
|
+
scenario_dir = root / "testql-scenarios"
|
|
156
|
+
scenario_dir.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
scenario_file = scenario_dir / "connect-config-smoke.testql.toon.yaml"
|
|
158
|
+
scenario_file.write_text("name: smoke\n", encoding="utf-8")
|
|
159
|
+
|
|
160
|
+
watcher = TestQLWatcher(
|
|
161
|
+
project_root=str(root),
|
|
162
|
+
deps_file=str(root / "deps.json"),
|
|
163
|
+
scenarios_dir="testql-scenarios",
|
|
164
|
+
track_dir=".wup/tracks",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# 1) First quick run fails -> service goes down
|
|
168
|
+
def failing_run(args, timeout):
|
|
169
|
+
return CompletedProcess(args=args, returncode=1, stdout="", stderr="down")
|
|
170
|
+
|
|
171
|
+
watcher._run_testql = failing_run # type: ignore[method-assign]
|
|
172
|
+
failed = asyncio.run(watcher.run_quick_test("connect-config", []))
|
|
173
|
+
assert failed is False
|
|
174
|
+
|
|
175
|
+
health_state_path = root / ".wup" / "service-health.json"
|
|
176
|
+
health_events_path = root / ".wup" / "service-health-events.jsonl"
|
|
177
|
+
assert health_state_path.exists()
|
|
178
|
+
assert health_events_path.exists()
|
|
179
|
+
|
|
180
|
+
state = json.loads(health_state_path.read_text(encoding="utf-8"))
|
|
181
|
+
assert state["connect-config"]["status"] == "down"
|
|
182
|
+
|
|
183
|
+
# 2) Next quick run succeeds -> service goes up
|
|
184
|
+
def passing_run(args, timeout):
|
|
185
|
+
return CompletedProcess(args=args, returncode=0, stdout="ok", stderr="")
|
|
186
|
+
|
|
187
|
+
watcher._run_testql = passing_run # type: ignore[method-assign]
|
|
188
|
+
passed = asyncio.run(watcher.run_quick_test("connect-config", []))
|
|
189
|
+
assert passed is True
|
|
190
|
+
|
|
191
|
+
state = json.loads(health_state_path.read_text(encoding="utf-8"))
|
|
192
|
+
assert state["connect-config"]["status"] == "up"
|
|
193
|
+
|
|
194
|
+
events = []
|
|
195
|
+
with health_events_path.open("r", encoding="utf-8") as handle:
|
|
196
|
+
for line in handle:
|
|
197
|
+
events.append(json.loads(line))
|
|
198
|
+
|
|
199
|
+
statuses = [event.get("status") for event in events if event.get("service") == "connect-config"]
|
|
200
|
+
assert "down" in statuses
|
|
201
|
+
assert "up" in statuses
|
|
@@ -7,7 +7,7 @@ WUP monitors file changes and runs intelligent regression tests using a 3-layer
|
|
|
7
7
|
3. Detail Layer: Full tests with blame reports (only on failure)
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
__version__ = "0.2.
|
|
10
|
+
__version__ = "0.2.12"
|
|
11
11
|
__author__ = "Tom Sapletta"
|
|
12
12
|
|
|
13
13
|
from .config import load_config, save_config, get_default_config
|
|
@@ -171,77 +171,124 @@ def map_deps(
|
|
|
171
171
|
def status(
|
|
172
172
|
deps_file: str = typer.Option("deps.json", "--deps", "-d", help="Path to dependency map file"),
|
|
173
173
|
config: Optional[str] = typer.Option(None, "--config", "-C", help="Path to wup.yaml config file"),
|
|
174
|
+
delta_seconds: int = typer.Option(0, "--delta-seconds", help="Show only service health transitions from last N seconds"),
|
|
175
|
+
failed_only: bool = typer.Option(False, "--failed-only", help="Show only currently failing services"),
|
|
176
|
+
watch: bool = typer.Option(False, "--watch", "-w", help="Live mode: refresh display in real time"),
|
|
177
|
+
interval: int = typer.Option(5, "--interval", "-i", help="Refresh interval in seconds for --watch mode"),
|
|
174
178
|
):
|
|
175
179
|
"""
|
|
176
180
|
Show dependency map status and configuration.
|
|
177
181
|
"""
|
|
182
|
+
import json
|
|
183
|
+
import time
|
|
184
|
+
|
|
178
185
|
project_path = Path(".").resolve()
|
|
179
|
-
|
|
180
|
-
# Load configuration
|
|
181
186
|
config_path = Path(config) if config else None
|
|
182
187
|
wup_config = load_config(project_path, config_path)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
console.print(f"[dim]Description: {wup_config.project.description}[/dim]")
|
|
187
|
-
console.print()
|
|
188
|
-
|
|
189
|
-
# Show watch configuration
|
|
190
|
-
console.print("[bold]Watch Configuration:[/bold]")
|
|
191
|
-
console.print(f" Paths: {', '.join(wup_config.watch.paths) if wup_config.watch.paths else 'default'}")
|
|
192
|
-
console.print(f" Excludes: {', '.join(wup_config.watch.exclude_patterns)}")
|
|
193
|
-
console.print()
|
|
194
|
-
|
|
195
|
-
# Show services
|
|
196
|
-
if wup_config.services:
|
|
197
|
-
console.print(f"[bold]Services ({len(wup_config.services)}):[/bold]")
|
|
198
|
-
for svc in wup_config.services:
|
|
199
|
-
console.print(f" [cyan]{svc.name}[/cyan]")
|
|
200
|
-
console.print(f" Root: {svc.root}")
|
|
201
|
-
console.print(f" Quick: scope={svc.quick_tests.scope}, max={svc.quick_tests.max_endpoints}")
|
|
202
|
-
console.print(f" Detail: scope={svc.detail_tests.scope}, max={svc.detail_tests.max_endpoints}")
|
|
203
|
-
console.print()
|
|
204
|
-
|
|
205
|
-
# Show dependency map status
|
|
188
|
+
health_state_path = project_path / ".wup" / "service-health.json"
|
|
189
|
+
health_events_path = project_path / ".wup" / "service-health-events.jsonl"
|
|
190
|
+
|
|
206
191
|
deps_path = Path(deps_file)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
console
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
192
|
+
|
|
193
|
+
def _build_panel(ts: float) -> "Group":
|
|
194
|
+
from rich.console import Group
|
|
195
|
+
from rich.text import Text
|
|
196
|
+
from rich.padding import Padding
|
|
197
|
+
lines: list = []
|
|
198
|
+
|
|
199
|
+
# header
|
|
200
|
+
lines.append(Text.from_markup(
|
|
201
|
+
f"[bold cyan]📊 WUP Status[/bold cyan] "
|
|
202
|
+
f"[dim]{wup_config.project.name}[/dim] "
|
|
203
|
+
f"[dim]updated {time.strftime('%H:%M:%S', time.localtime(ts))}[/dim]"
|
|
204
|
+
))
|
|
205
|
+
|
|
206
|
+
# --- failing services ---
|
|
207
|
+
health_state: dict = {}
|
|
208
|
+
if health_state_path.exists():
|
|
209
|
+
try:
|
|
210
|
+
payload = json.loads(health_state_path.read_text(encoding="utf-8"))
|
|
211
|
+
if isinstance(payload, dict):
|
|
212
|
+
health_state = payload
|
|
213
|
+
except json.JSONDecodeError:
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
if failed_only or watch:
|
|
217
|
+
failing = [
|
|
218
|
+
(svc, data)
|
|
219
|
+
for svc, data in sorted(health_state.items())
|
|
220
|
+
if isinstance(data, dict) and data.get("status") == "down"
|
|
221
|
+
]
|
|
222
|
+
lines.append(Text(""))
|
|
223
|
+
lines.append(Text.from_markup("[bold]Currently failing services:[/bold]"))
|
|
224
|
+
if not failing:
|
|
225
|
+
lines.append(Text.from_markup(" [green]✓ None[/green]"))
|
|
226
|
+
else:
|
|
227
|
+
for svc, data in failing:
|
|
228
|
+
stage = data.get("stage", "")
|
|
229
|
+
message = data.get("message", "")
|
|
230
|
+
track_file = data.get("track_file", "")
|
|
231
|
+
lines.append(Text.from_markup(f" [red]✗ {svc}[/red] [dim]{stage}[/dim]"))
|
|
232
|
+
if message:
|
|
233
|
+
lines.append(Text.from_markup(f" [dim]{message}[/dim]"))
|
|
234
|
+
if track_file:
|
|
235
|
+
lines.append(Text.from_markup(f" [dim]track: {track_file}[/dim]"))
|
|
236
|
+
|
|
237
|
+
# --- delta ---
|
|
238
|
+
effective_delta = delta_seconds if delta_seconds > 0 else (30 if watch else 0)
|
|
239
|
+
if effective_delta > 0:
|
|
240
|
+
cutoff = int(ts) - effective_delta
|
|
241
|
+
recent_events: list = []
|
|
242
|
+
if health_events_path.exists():
|
|
243
|
+
with health_events_path.open("r", encoding="utf-8") as handle:
|
|
244
|
+
for line in handle:
|
|
245
|
+
line = line.strip()
|
|
246
|
+
if not line:
|
|
247
|
+
continue
|
|
248
|
+
try:
|
|
249
|
+
event = json.loads(line)
|
|
250
|
+
except json.JSONDecodeError:
|
|
251
|
+
continue
|
|
252
|
+
if int(event.get("timestamp", 0)) >= cutoff:
|
|
253
|
+
recent_events.append(event)
|
|
254
|
+
|
|
255
|
+
lines.append(Text(""))
|
|
256
|
+
lines.append(Text.from_markup(f"[bold]Service health delta (last {effective_delta}s):[/bold]"))
|
|
257
|
+
if not recent_events:
|
|
258
|
+
lines.append(Text.from_markup(" [yellow]No health transitions in selected window[/yellow]"))
|
|
236
259
|
else:
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
260
|
+
recent_events.sort(key=lambda e: int(e.get("timestamp", 0)), reverse=True)
|
|
261
|
+
for event in recent_events:
|
|
262
|
+
svc = event.get("service", "unknown")
|
|
263
|
+
prev = event.get("previous_status", "unknown")
|
|
264
|
+
curr = event.get("status", "unknown")
|
|
265
|
+
stage = event.get("stage", "")
|
|
266
|
+
message = event.get("message", "")
|
|
267
|
+
track_file = event.get("track_file", "")
|
|
268
|
+
arrow_color = "green" if curr == "up" else "red"
|
|
269
|
+
lines.append(Text.from_markup(
|
|
270
|
+
f" [cyan]{svc}[/cyan]: {prev} [bold {arrow_color}]→ {curr}[/bold {arrow_color}] [dim]({stage})[/dim]"
|
|
271
|
+
))
|
|
272
|
+
if message:
|
|
273
|
+
lines.append(Text.from_markup(f" [dim]{message}[/dim]"))
|
|
274
|
+
if track_file:
|
|
275
|
+
lines.append(Text.from_markup(f" [dim]track: {track_file}[/dim]"))
|
|
276
|
+
|
|
277
|
+
return Group(*lines)
|
|
278
|
+
|
|
279
|
+
if not watch:
|
|
280
|
+
console.print(_build_panel(time.time()))
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
# --- live / watch mode ---
|
|
284
|
+
from rich.live import Live
|
|
285
|
+
try:
|
|
286
|
+
with Live(_build_panel(time.time()), refresh_per_second=1, console=console) as live:
|
|
287
|
+
while True:
|
|
288
|
+
time.sleep(interval)
|
|
289
|
+
live.update(_build_panel(time.time()))
|
|
290
|
+
except KeyboardInterrupt:
|
|
291
|
+
pass
|
|
245
292
|
|
|
246
293
|
|
|
247
294
|
@app.command()
|
|
@@ -132,11 +132,19 @@ class WupWatcher:
|
|
|
132
132
|
if re.search(pattern, path_lower):
|
|
133
133
|
return svc.name
|
|
134
134
|
|
|
135
|
+
# Heuristic: if top-level directory matches known prefix patterns (e.g. connect-*)
|
|
136
|
+
# use it directly as the service name — takes priority over stale deps.json
|
|
137
|
+
if parts:
|
|
138
|
+
top = parts[0]
|
|
139
|
+
import re as _re
|
|
140
|
+
if _re.match(r'^(connect|backend|frontend|api|app|worker|service)[-_]', top):
|
|
141
|
+
return top
|
|
142
|
+
|
|
135
143
|
# Use dependency mapper if available
|
|
136
144
|
service = self.dependency_mapper.get_service_for_file(file_path)
|
|
137
145
|
if service:
|
|
138
146
|
return service
|
|
139
|
-
|
|
147
|
+
|
|
140
148
|
# Fallback: use first two meaningful parts (only if file exists)
|
|
141
149
|
if len(parts) >= 2:
|
|
142
150
|
# Check if file exists (absolute path)
|
|
@@ -51,10 +51,12 @@ class TestQLWatcher(WupWatcher):
|
|
|
51
51
|
|
|
52
52
|
__test__ = False
|
|
53
53
|
|
|
54
|
+
_UNSET = object()
|
|
55
|
+
|
|
54
56
|
def __init__(
|
|
55
57
|
self,
|
|
56
58
|
project_root: str,
|
|
57
|
-
scenarios_dir
|
|
59
|
+
scenarios_dir=_UNSET,
|
|
58
60
|
testql_bin: str = "testql",
|
|
59
61
|
track_dir: str = ".wup/tracks",
|
|
60
62
|
browser_service_url: Optional[str] = None,
|
|
@@ -69,11 +71,13 @@ class TestQLWatcher(WupWatcher):
|
|
|
69
71
|
# Pass config to parent class
|
|
70
72
|
super().__init__(project_root=project_root, config=config, **kwargs)
|
|
71
73
|
|
|
72
|
-
#
|
|
73
|
-
if
|
|
74
|
+
# Explicit constructor arg wins; otherwise use config; final fallback default
|
|
75
|
+
if scenarios_dir is not self._UNSET:
|
|
76
|
+
self.scenarios_dir = self.project_root / scenarios_dir
|
|
77
|
+
elif config and config.testql and config.testql.scenario_dir:
|
|
74
78
|
self.scenarios_dir = self.project_root / config.testql.scenario_dir
|
|
75
79
|
else:
|
|
76
|
-
self.scenarios_dir = self.project_root /
|
|
80
|
+
self.scenarios_dir = self.project_root / "testql-scenarios"
|
|
77
81
|
self.testql_bin = testql_bin
|
|
78
82
|
self.testql_extra_args = config.testql.extra_args if config and config.testql else []
|
|
79
83
|
|
|
@@ -85,8 +89,79 @@ class TestQLWatcher(WupWatcher):
|
|
|
85
89
|
events_file=self.project_root / ".wup" / "browser-events" / "latest.json",
|
|
86
90
|
)
|
|
87
91
|
self.last_track_path: Optional[Path] = None
|
|
92
|
+
self.health_state_path = self.project_root / ".wup" / "service-health.json"
|
|
93
|
+
self.health_events_path = self.project_root / ".wup" / "service-health-events.jsonl"
|
|
94
|
+
self.health_state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
self.service_health = self._load_service_health()
|
|
88
96
|
self.config = config
|
|
89
97
|
|
|
98
|
+
def _load_service_health(self) -> Dict[str, Dict]:
|
|
99
|
+
if not self.health_state_path.exists():
|
|
100
|
+
return {}
|
|
101
|
+
try:
|
|
102
|
+
payload = json.loads(self.health_state_path.read_text(encoding="utf-8"))
|
|
103
|
+
if isinstance(payload, dict):
|
|
104
|
+
return payload
|
|
105
|
+
except (json.JSONDecodeError, OSError):
|
|
106
|
+
return {}
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
def _save_service_health(self) -> None:
|
|
110
|
+
self.health_state_path.write_text(
|
|
111
|
+
json.dumps(self.service_health, indent=2),
|
|
112
|
+
encoding="utf-8",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def _record_health_transition(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
service: str,
|
|
119
|
+
status: str,
|
|
120
|
+
stage: str,
|
|
121
|
+
message: str = "",
|
|
122
|
+
track_file: Optional[str] = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
now = int(time.time())
|
|
125
|
+
previous = self.service_health.get(service, {})
|
|
126
|
+
previous_status = previous.get("status", "unknown")
|
|
127
|
+
|
|
128
|
+
self.service_health[service] = {
|
|
129
|
+
"status": status,
|
|
130
|
+
"updated_at": now,
|
|
131
|
+
"stage": stage,
|
|
132
|
+
"message": message,
|
|
133
|
+
"track_file": track_file or "",
|
|
134
|
+
}
|
|
135
|
+
self._save_service_health()
|
|
136
|
+
|
|
137
|
+
changed = previous_status != status
|
|
138
|
+
if not changed:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
event = {
|
|
142
|
+
"timestamp": now,
|
|
143
|
+
"service": service,
|
|
144
|
+
"status": status,
|
|
145
|
+
"previous_status": previous_status,
|
|
146
|
+
"stage": stage,
|
|
147
|
+
"message": message,
|
|
148
|
+
"track_file": track_file or "",
|
|
149
|
+
}
|
|
150
|
+
with self.health_events_path.open("a", encoding="utf-8") as handle:
|
|
151
|
+
handle.write(json.dumps(event) + "\n")
|
|
152
|
+
|
|
153
|
+
self.browser_notifier.notify(
|
|
154
|
+
{
|
|
155
|
+
"type": "wup_service_health_change",
|
|
156
|
+
"service": service,
|
|
157
|
+
"status": status,
|
|
158
|
+
"previous_status": previous_status,
|
|
159
|
+
"stage": stage,
|
|
160
|
+
"message": message,
|
|
161
|
+
"track_file": track_file,
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
|
|
90
165
|
def _tokenize_service(self, service: str) -> List[str]:
|
|
91
166
|
raw_tokens = re.split(r"[^a-zA-Z0-9]+", service.lower())
|
|
92
167
|
return [token for token in raw_tokens if len(token) >= 3]
|
|
@@ -98,8 +173,9 @@ class TestQLWatcher(WupWatcher):
|
|
|
98
173
|
service_specific = by_service.get(service, [])
|
|
99
174
|
merged: List[str] = []
|
|
100
175
|
for endpoint in [*service_specific, *explicit]:
|
|
101
|
-
|
|
102
|
-
|
|
176
|
+
endpoint_url = self._to_full_url(endpoint)
|
|
177
|
+
if endpoint_url not in merged:
|
|
178
|
+
merged.append(endpoint_url)
|
|
103
179
|
return merged
|
|
104
180
|
|
|
105
181
|
def _resolve_base_url(self) -> str:
|
|
@@ -284,11 +360,24 @@ class TestQLWatcher(WupWatcher):
|
|
|
284
360
|
scenario=scenario,
|
|
285
361
|
result=result,
|
|
286
362
|
)
|
|
363
|
+
self._record_health_transition(
|
|
364
|
+
service=service,
|
|
365
|
+
status="down",
|
|
366
|
+
stage="quick",
|
|
367
|
+
message=result.stderr.strip() or result.stdout.strip() or "Quick TestQL failed",
|
|
368
|
+
track_file=str(track_path),
|
|
369
|
+
)
|
|
287
370
|
self.console.print(
|
|
288
371
|
f"[red]✗ Quick failed: {scenario.name} | track: {track_path}[/red]"
|
|
289
372
|
)
|
|
290
373
|
return False
|
|
291
374
|
|
|
375
|
+
self._record_health_transition(
|
|
376
|
+
service=service,
|
|
377
|
+
status="up",
|
|
378
|
+
stage="quick",
|
|
379
|
+
message="Quick TestQL passed",
|
|
380
|
+
)
|
|
292
381
|
self.console.print(f"[green]✓ Quick TestQL passed for {service}[/green]")
|
|
293
382
|
return True
|
|
294
383
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wup
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.12
|
|
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
|
-
License
|
|
6
|
+
License: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/semcod/wup
|
|
8
8
|
Project-URL: Repository, https://github.com/semcod/wup
|
|
9
9
|
Keywords: wup,watcher,testing,regression,file-monitoring
|
|
@@ -21,6 +21,7 @@ Requires-Dist: watchdog>=4.0.0
|
|
|
21
21
|
Requires-Dist: psutil>=5.9.0
|
|
22
22
|
Requires-Dist: rich>=13.0.0
|
|
23
23
|
Requires-Dist: typer>=0.9.0
|
|
24
|
+
Requires-Dist: pyyaml>=6.0
|
|
24
25
|
Dynamic: license-file
|
|
25
26
|
|
|
26
27
|
# WUP (What's Up)
|
|
@@ -28,17 +29,17 @@ Dynamic: license-file
|
|
|
28
29
|
|
|
29
30
|
## AI Cost Tracking
|
|
30
31
|
|
|
31
|
-
    
|
|
33
|
+
  
|
|
33
34
|
|
|
34
|
-
- 🤖 **LLM usage:** $1.
|
|
35
|
-
- 👤 **Human dev:** ~$
|
|
35
|
+
- 🤖 **LLM usage:** $1.9500 (13 commits)
|
|
36
|
+
- 👤 **Human dev:** ~$312 (3.1h @ $100/h, 30min dedup)
|
|
36
37
|
|
|
37
38
|
Generated on 2026-04-29 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
38
39
|
|
|
39
40
|
---
|
|
40
41
|
|
|
41
|
-
    
|
|
42
43
|
|
|
43
44
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
44
45
|
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import json
|
|
3
|
-
import tempfile
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from subprocess import CompletedProcess
|
|
6
|
-
|
|
7
|
-
from wup.testql_watcher import TestQLWatcher
|
|
8
|
-
from wup.models.config import WupConfig, ProjectConfig
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def test_process_changed_file_creates_track_on_failure():
|
|
12
|
-
with tempfile.TemporaryDirectory() as tmpdir:
|
|
13
|
-
root = Path(tmpdir)
|
|
14
|
-
app_file = root / "app" / "users" / "routes.py"
|
|
15
|
-
app_file.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
-
app_file.write_text("print('x')\n", encoding="utf-8")
|
|
17
|
-
|
|
18
|
-
scenario_dir = root / "testql-scenarios"
|
|
19
|
-
scenario_dir.mkdir(parents=True, exist_ok=True)
|
|
20
|
-
failing_scenario = scenario_dir / "app-users.testql.toon.yaml"
|
|
21
|
-
failing_scenario.write_text("name: failing\n", encoding="utf-8")
|
|
22
|
-
|
|
23
|
-
# Pass empty config to prevent loading from temp dir
|
|
24
|
-
from wup.models.config import TestQLConfig
|
|
25
|
-
empty_config = WupConfig(
|
|
26
|
-
project=ProjectConfig(name="test"),
|
|
27
|
-
services=[],
|
|
28
|
-
test_strategy=None,
|
|
29
|
-
testql=TestQLConfig(scenario_dir="testql-scenarios")
|
|
30
|
-
)
|
|
31
|
-
watcher = TestQLWatcher(
|
|
32
|
-
project_root=str(root),
|
|
33
|
-
deps_file=str(root / "deps.json"),
|
|
34
|
-
scenarios_dir="testql-scenarios",
|
|
35
|
-
track_dir=".wup/tracks",
|
|
36
|
-
config=empty_config,
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
watcher.dependency_mapper.service_to_endpoints["app/users"] = ["/api/v1/users"]
|
|
40
|
-
|
|
41
|
-
def fake_run_testql(args, timeout):
|
|
42
|
-
if "--dry-run" in args:
|
|
43
|
-
return CompletedProcess(args=args, returncode=1, stdout="", stderr="intentional failure")
|
|
44
|
-
return CompletedProcess(args=args, returncode=0, stdout="{}", stderr="")
|
|
45
|
-
|
|
46
|
-
watcher._run_testql = fake_run_testql # type: ignore[method-assign]
|
|
47
|
-
|
|
48
|
-
result = asyncio.run(watcher.process_changed_file_once(str(app_file)))
|
|
49
|
-
|
|
50
|
-
assert result["processed_items"] >= 1
|
|
51
|
-
assert result["last_track_path"] is not None
|
|
52
|
-
|
|
53
|
-
track_path = Path(result["last_track_path"])
|
|
54
|
-
assert track_path.exists()
|
|
55
|
-
|
|
56
|
-
track_payload = json.loads(track_path.read_text(encoding="utf-8"))
|
|
57
|
-
assert track_payload["service"] == "app/users"
|
|
58
|
-
assert track_payload["stage"] == "quick"
|
|
59
|
-
assert "intentional failure" in track_payload["stderr_head"]
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def test_browser_event_file_is_written_without_service_url():
|
|
63
|
-
with tempfile.TemporaryDirectory() as tmpdir:
|
|
64
|
-
root = Path(tmpdir)
|
|
65
|
-
scenario_dir = root / "testql-scenarios"
|
|
66
|
-
scenario_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
-
scenario_file = scenario_dir / "api-users-smoke.testql.toon.yaml"
|
|
68
|
-
scenario_file.write_text("name: smoke\n", encoding="utf-8")
|
|
69
|
-
|
|
70
|
-
watcher = TestQLWatcher(
|
|
71
|
-
project_root=str(root),
|
|
72
|
-
deps_file=str(root / "deps.json"),
|
|
73
|
-
scenarios_dir="testql-scenarios",
|
|
74
|
-
track_dir=".wup/tracks",
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
result = CompletedProcess(args=["testql", "run"], returncode=1, stdout="", stderr="boom")
|
|
78
|
-
track_path = watcher._write_track(
|
|
79
|
-
service="app/users",
|
|
80
|
-
stage="quick",
|
|
81
|
-
scenario=scenario_file,
|
|
82
|
-
result=result,
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
assert track_path.exists()
|
|
86
|
-
event_file = root / ".wup" / "browser-events" / "latest.json"
|
|
87
|
-
assert event_file.exists()
|
|
88
|
-
event_payload = json.loads(event_file.read_text(encoding="utf-8"))
|
|
89
|
-
assert event_payload["type"] == "wup_testql_error"
|
|
90
|
-
assert event_payload["service"] == "app/users"
|
{wup-0.2.9 → wup-0.2.12}/LICENSE
RENAMED
|
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
|