wup 0.2.8__tar.gz → 0.2.11__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.8/wup.egg-info → wup-0.2.11}/PKG-INFO +8 -7
- {wup-0.2.8 → wup-0.2.11}/README.md +5 -5
- {wup-0.2.8 → wup-0.2.11}/pyproject.toml +7 -2
- {wup-0.2.8 → wup-0.2.11}/tests/test_e2e.py +37 -31
- wup-0.2.11/tests/test_testql_watcher.py +201 -0
- {wup-0.2.8 → wup-0.2.11}/tests/test_wup.py +41 -0
- {wup-0.2.8 → wup-0.2.11}/wup/__init__.py +1 -1
- {wup-0.2.8 → wup-0.2.11}/wup/cli.py +77 -0
- {wup-0.2.8 → wup-0.2.11}/wup/config.py +11 -2
- {wup-0.2.8 → wup-0.2.11}/wup/core.py +33 -1
- {wup-0.2.8 → wup-0.2.11}/wup/models/config.py +4 -0
- {wup-0.2.8 → wup-0.2.11}/wup/testql_watcher.py +144 -11
- {wup-0.2.8 → wup-0.2.11/wup.egg-info}/PKG-INFO +8 -7
- {wup-0.2.8 → wup-0.2.11}/wup.egg-info/requires.txt +1 -0
- wup-0.2.8/tests/test_testql_watcher.py +0 -89
- {wup-0.2.8 → wup-0.2.11}/LICENSE +0 -0
- {wup-0.2.8 → wup-0.2.11}/setup.cfg +0 -0
- {wup-0.2.8 → wup-0.2.11}/wup/dependency_mapper.py +0 -0
- {wup-0.2.8 → wup-0.2.11}/wup/models/__init__.py +0 -0
- {wup-0.2.8 → wup-0.2.11}/wup/testql_discovery.py +0 -0
- {wup-0.2.8 → wup-0.2.11}/wup.egg-info/SOURCES.txt +0 -0
- {wup-0.2.8 → wup-0.2.11}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.8 → wup-0.2.11}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.8 → wup-0.2.11}/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.11
|
|
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.8000 (12 commits)
|
|
36
|
+
- 👤 **Human dev:** ~$290 (2.9h @ $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.8000 (12 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$290 (2.9h @ $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.11"
|
|
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
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""End-to-end tests for WUP CLI and workflows."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
import subprocess
|
|
5
6
|
import sys
|
|
6
7
|
import tempfile
|
|
@@ -11,13 +12,29 @@ from typing import List
|
|
|
11
12
|
import pytest
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
def run_wup_command(args, cwd=None, timeout=30, capture_output=True, text=True):
|
|
16
|
+
"""Helper to run WUP commands with PYTHONPATH set."""
|
|
17
|
+
env = os.environ.copy()
|
|
18
|
+
# Add project root to PYTHONPATH so subprocess can find wup module
|
|
19
|
+
project_root = Path(__file__).parent.parent
|
|
20
|
+
env["PYTHONPATH"] = str(project_root) + ":" + env.get("PYTHONPATH", "")
|
|
21
|
+
return subprocess.run(
|
|
22
|
+
args,
|
|
23
|
+
cwd=cwd,
|
|
24
|
+
capture_output=capture_output,
|
|
25
|
+
text=text,
|
|
26
|
+
timeout=timeout,
|
|
27
|
+
env=env
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
14
31
|
class TestE2ECLI:
|
|
15
32
|
"""End-to-end tests for CLI commands."""
|
|
16
33
|
|
|
17
34
|
def test_cli_init_creates_config_file(self):
|
|
18
35
|
"""Test that wup init creates a wup.yaml configuration file."""
|
|
19
36
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
20
|
-
result =
|
|
37
|
+
result = run_wup_command(
|
|
21
38
|
[sys.executable, "-m", "wup.cli", "init", "--output", str(Path(tmpdir) / "wup.yaml")],
|
|
22
39
|
cwd=tmpdir,
|
|
23
40
|
capture_output=True,
|
|
@@ -37,7 +54,7 @@ class TestE2ECLI:
|
|
|
37
54
|
def test_cli_init_default_location(self):
|
|
38
55
|
"""Test that wup init creates wup.yaml in current directory by default."""
|
|
39
56
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
40
|
-
result =
|
|
57
|
+
result = run_wup_command(
|
|
41
58
|
[sys.executable, "-m", "wup.cli", "init"],
|
|
42
59
|
cwd=tmpdir,
|
|
43
60
|
capture_output=True,
|
|
@@ -67,7 +84,7 @@ def get_users():
|
|
|
67
84
|
return []
|
|
68
85
|
""")
|
|
69
86
|
|
|
70
|
-
result =
|
|
87
|
+
result = run_wup_command(
|
|
71
88
|
[sys.executable, "-m", "wup.cli", "map-deps", tmpdir, "--framework", "fastapi"],
|
|
72
89
|
cwd=tmpdir,
|
|
73
90
|
capture_output=True,
|
|
@@ -98,7 +115,7 @@ def get_users():
|
|
|
98
115
|
"files": {"app/users/routes.py": ["/users"]}
|
|
99
116
|
}))
|
|
100
117
|
|
|
101
|
-
result =
|
|
118
|
+
result = run_wup_command(
|
|
102
119
|
[sys.executable, "-m", "wup.cli", "status", "--deps", str(deps_file)],
|
|
103
120
|
cwd=tmpdir,
|
|
104
121
|
capture_output=True,
|
|
@@ -118,25 +135,23 @@ class TestE2EWorkflow:
|
|
|
118
135
|
"""Test complete workflow from config to file watching."""
|
|
119
136
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
120
137
|
# Initialize config
|
|
121
|
-
|
|
138
|
+
run_wup_command(
|
|
122
139
|
[sys.executable, "-m", "wup.cli", "init"],
|
|
123
140
|
cwd=tmpdir,
|
|
124
|
-
capture_output=True,
|
|
125
141
|
timeout=10
|
|
126
142
|
)
|
|
127
|
-
|
|
143
|
+
|
|
128
144
|
# Create project structure
|
|
129
145
|
app_dir = Path(tmpdir) / "app" / "users"
|
|
130
146
|
app_dir.mkdir(parents=True)
|
|
131
|
-
|
|
147
|
+
|
|
132
148
|
routes_file = app_dir / "routes.py"
|
|
133
149
|
routes_file.write_text("def handler(): pass\n")
|
|
134
|
-
|
|
150
|
+
|
|
135
151
|
# Build dependencies
|
|
136
|
-
|
|
152
|
+
run_wup_command(
|
|
137
153
|
[sys.executable, "-m", "wup.cli", "map-deps", tmpdir, "--framework", "fastapi"],
|
|
138
154
|
cwd=tmpdir,
|
|
139
|
-
capture_output=True,
|
|
140
155
|
timeout=30
|
|
141
156
|
)
|
|
142
157
|
|
|
@@ -186,19 +201,10 @@ test_strategy:
|
|
|
186
201
|
routes_file.write_text("def handler(): pass\n")
|
|
187
202
|
|
|
188
203
|
# Build dependencies
|
|
189
|
-
|
|
190
|
-
env = os.environ.copy()
|
|
191
|
-
# Add project root to PYTHONPATH so subprocess can find wup module
|
|
192
|
-
project_root = Path(__file__).parent.parent
|
|
193
|
-
env["PYTHONPATH"] = str(project_root) + ":" + env.get("PYTHONPATH", "")
|
|
194
|
-
|
|
195
|
-
result = subprocess.run(
|
|
204
|
+
result = run_wup_command(
|
|
196
205
|
[sys.executable, "-m", "wup.cli", "map-deps", tmpdir],
|
|
197
206
|
cwd=tmpdir,
|
|
198
|
-
|
|
199
|
-
text=True,
|
|
200
|
-
timeout=30,
|
|
201
|
-
env=env
|
|
207
|
+
timeout=30
|
|
202
208
|
)
|
|
203
209
|
|
|
204
210
|
assert result.returncode == 0
|
|
@@ -241,7 +247,7 @@ services:
|
|
|
241
247
|
md_file.write_text("# API\n")
|
|
242
248
|
|
|
243
249
|
# Build dependencies
|
|
244
|
-
result =
|
|
250
|
+
result = run_wup_command(
|
|
245
251
|
[sys.executable, "-m", "wup.cli", "map-deps", tmpdir],
|
|
246
252
|
cwd=tmpdir,
|
|
247
253
|
capture_output=True,
|
|
@@ -312,7 +318,7 @@ def login():
|
|
|
312
318
|
""")
|
|
313
319
|
|
|
314
320
|
# Build dependencies for FastAPI
|
|
315
|
-
result =
|
|
321
|
+
result = run_wup_command(
|
|
316
322
|
[sys.executable, "-m", "wup.cli", "map-deps", tmpdir, "--framework", "fastapi"],
|
|
317
323
|
cwd=tmpdir,
|
|
318
324
|
capture_output=True,
|
|
@@ -338,7 +344,7 @@ class TestE2EErrorHandling:
|
|
|
338
344
|
config_file = Path(tmpdir) / "wup.yaml"
|
|
339
345
|
config_file.write_text("invalid: yaml: content: [")
|
|
340
346
|
|
|
341
|
-
result =
|
|
347
|
+
result = run_wup_command(
|
|
342
348
|
[sys.executable, "-m", "wup.cli", "status"],
|
|
343
349
|
cwd=tmpdir,
|
|
344
350
|
capture_output=True,
|
|
@@ -351,7 +357,7 @@ class TestE2EErrorHandling:
|
|
|
351
357
|
|
|
352
358
|
def test_cli_handles_missing_project(self):
|
|
353
359
|
"""Test that CLI handles missing project directory."""
|
|
354
|
-
result =
|
|
360
|
+
result = run_wup_command(
|
|
355
361
|
[sys.executable, "-m", "wup.cli", "map-deps", "/nonexistent/path"],
|
|
356
362
|
capture_output=True,
|
|
357
363
|
text=True,
|
|
@@ -364,7 +370,7 @@ class TestE2EErrorHandling:
|
|
|
364
370
|
def test_cli_handles_empty_project(self):
|
|
365
371
|
"""Test that CLI handles empty project directory."""
|
|
366
372
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
367
|
-
result =
|
|
373
|
+
result = run_wup_command(
|
|
368
374
|
[sys.executable, "-m", "wup.cli", "map-deps", tmpdir],
|
|
369
375
|
cwd=tmpdir,
|
|
370
376
|
capture_output=True,
|
|
@@ -394,7 +400,7 @@ class TestE2EPerformance:
|
|
|
394
400
|
routes_file.write_text("def handler(): pass\n")
|
|
395
401
|
|
|
396
402
|
start_time = time.time()
|
|
397
|
-
result =
|
|
403
|
+
result = run_wup_command(
|
|
398
404
|
[sys.executable, "-m", "wup.cli", "map-deps", tmpdir],
|
|
399
405
|
cwd=tmpdir,
|
|
400
406
|
capture_output=True,
|
|
@@ -409,7 +415,7 @@ class TestE2EPerformance:
|
|
|
409
415
|
"""Test init command performance."""
|
|
410
416
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
411
417
|
start_time = time.time()
|
|
412
|
-
result =
|
|
418
|
+
result = run_wup_command(
|
|
413
419
|
[sys.executable, "-m", "wup.cli", "init"],
|
|
414
420
|
cwd=tmpdir,
|
|
415
421
|
capture_output=True,
|
|
@@ -461,7 +467,7 @@ services:
|
|
|
461
467
|
routes_file.write_text("def handler(): pass\n")
|
|
462
468
|
|
|
463
469
|
# Build dependencies
|
|
464
|
-
result =
|
|
470
|
+
result = run_wup_command(
|
|
465
471
|
[sys.executable, "-m", "wup.cli", "map-deps", tmpdir],
|
|
466
472
|
cwd=tmpdir,
|
|
467
473
|
capture_output=True,
|
|
@@ -500,7 +506,7 @@ services:
|
|
|
500
506
|
routes_file.write_text("def handler(): pass\n")
|
|
501
507
|
|
|
502
508
|
# Build dependencies
|
|
503
|
-
result =
|
|
509
|
+
result = run_wup_command(
|
|
504
510
|
[sys.executable, "-m", "wup.cli", "map-deps", tmpdir],
|
|
505
511
|
cwd=tmpdir,
|
|
506
512
|
capture_output=True,
|
|
@@ -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
|
|
@@ -790,6 +790,47 @@ def test_import():
|
|
|
790
790
|
from wup import WupWatcher, DependencyMapper # noqa: F401
|
|
791
791
|
|
|
792
792
|
|
|
793
|
+
class TestFileFiltering:
|
|
794
|
+
"""Tests for file type filtering."""
|
|
795
|
+
|
|
796
|
+
def test_should_watch_file_with_config(self):
|
|
797
|
+
"""Test file filtering with configured file types."""
|
|
798
|
+
from wup.models.config import WupConfig, ProjectConfig, WatchConfig
|
|
799
|
+
|
|
800
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
801
|
+
config = WupConfig(
|
|
802
|
+
project=ProjectConfig(name="test"),
|
|
803
|
+
watch=WatchConfig(file_types=[".py", ".ts", ".tsx", ".js"])
|
|
804
|
+
)
|
|
805
|
+
watcher = WupWatcher(tmpdir, config=config)
|
|
806
|
+
|
|
807
|
+
# Should watch allowed types
|
|
808
|
+
assert watcher.should_watch_file(str(Path(tmpdir) / "app.py"))
|
|
809
|
+
assert watcher.should_watch_file(str(Path(tmpdir) / "component.ts"))
|
|
810
|
+
assert watcher.should_watch_file(str(Path(tmpdir) / "app.tsx"))
|
|
811
|
+
assert watcher.should_watch_file(str(Path(tmpdir) / "main.js"))
|
|
812
|
+
|
|
813
|
+
# Should skip disallowed types
|
|
814
|
+
assert not watcher.should_watch_file(str(Path(tmpdir) / "README.md"))
|
|
815
|
+
assert not watcher.should_watch_file(str(Path(tmpdir) / "config.yaml"))
|
|
816
|
+
|
|
817
|
+
def test_should_watch_file_without_config(self):
|
|
818
|
+
"""Test file filtering without configured file types (watch all)."""
|
|
819
|
+
from wup.models.config import WupConfig, ProjectConfig, WatchConfig
|
|
820
|
+
|
|
821
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
822
|
+
config = WupConfig(
|
|
823
|
+
project=ProjectConfig(name="test"),
|
|
824
|
+
watch=WatchConfig(file_types=[])
|
|
825
|
+
)
|
|
826
|
+
watcher = WupWatcher(tmpdir, config=config)
|
|
827
|
+
|
|
828
|
+
# Should watch all files when no filter configured
|
|
829
|
+
assert watcher.should_watch_file(str(Path(tmpdir) / "app.py"))
|
|
830
|
+
assert watcher.should_watch_file(str(Path(tmpdir) / "README.md"))
|
|
831
|
+
assert watcher.should_watch_file(str(Path(tmpdir) / "config.yaml"))
|
|
832
|
+
|
|
833
|
+
|
|
793
834
|
class TestConfigModels:
|
|
794
835
|
"""Tests for configuration dataclasses."""
|
|
795
836
|
|
|
@@ -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.11"
|
|
11
11
|
__author__ = "Tom Sapletta"
|
|
12
12
|
|
|
13
13
|
from .config import load_config, save_config, get_default_config
|
|
@@ -171,6 +171,8 @@ 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"),
|
|
174
176
|
):
|
|
175
177
|
"""
|
|
176
178
|
Show dependency map status and configuration.
|
|
@@ -243,6 +245,81 @@ def status(
|
|
|
243
245
|
if endpoints:
|
|
244
246
|
console.print(f" Sample endpoints: {', '.join(endpoints[:3])}")
|
|
245
247
|
|
|
248
|
+
# Show service health state and recent transitions (TestQL watcher)
|
|
249
|
+
health_state_path = project_path / ".wup" / "service-health.json"
|
|
250
|
+
health_events_path = project_path / ".wup" / "service-health-events.jsonl"
|
|
251
|
+
|
|
252
|
+
health_state = {}
|
|
253
|
+
if health_state_path.exists():
|
|
254
|
+
import json
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
payload = json.loads(health_state_path.read_text(encoding="utf-8"))
|
|
258
|
+
if isinstance(payload, dict):
|
|
259
|
+
health_state = payload
|
|
260
|
+
except json.JSONDecodeError:
|
|
261
|
+
health_state = {}
|
|
262
|
+
|
|
263
|
+
if failed_only:
|
|
264
|
+
failing = [
|
|
265
|
+
(svc, data)
|
|
266
|
+
for svc, data in sorted(health_state.items())
|
|
267
|
+
if isinstance(data, dict) and data.get("status") == "down"
|
|
268
|
+
]
|
|
269
|
+
console.print()
|
|
270
|
+
console.print("[bold]Currently failing services:[/bold]")
|
|
271
|
+
if not failing:
|
|
272
|
+
console.print(" [green]None[/green]")
|
|
273
|
+
else:
|
|
274
|
+
for svc, data in failing:
|
|
275
|
+
updated_at = data.get("updated_at", 0)
|
|
276
|
+
track_file = data.get("track_file", "")
|
|
277
|
+
stage = data.get("stage", "")
|
|
278
|
+
message = data.get("message", "")
|
|
279
|
+
console.print(f" [red]{svc}[/red] stage={stage} updated_at={updated_at}")
|
|
280
|
+
if track_file:
|
|
281
|
+
console.print(f" track: {track_file}")
|
|
282
|
+
if message:
|
|
283
|
+
console.print(f" message: {message}")
|
|
284
|
+
|
|
285
|
+
if delta_seconds > 0:
|
|
286
|
+
import json
|
|
287
|
+
import time
|
|
288
|
+
|
|
289
|
+
cutoff = int(time.time()) - delta_seconds
|
|
290
|
+
recent_events = []
|
|
291
|
+
if health_events_path.exists():
|
|
292
|
+
with health_events_path.open("r", encoding="utf-8") as handle:
|
|
293
|
+
for line in handle:
|
|
294
|
+
line = line.strip()
|
|
295
|
+
if not line:
|
|
296
|
+
continue
|
|
297
|
+
try:
|
|
298
|
+
event = json.loads(line)
|
|
299
|
+
except json.JSONDecodeError:
|
|
300
|
+
continue
|
|
301
|
+
if int(event.get("timestamp", 0)) >= cutoff:
|
|
302
|
+
recent_events.append(event)
|
|
303
|
+
|
|
304
|
+
console.print()
|
|
305
|
+
console.print(f"[bold]Service health delta (last {delta_seconds}s):[/bold]")
|
|
306
|
+
if not recent_events:
|
|
307
|
+
console.print(" [yellow]No health transitions in selected window[/yellow]")
|
|
308
|
+
else:
|
|
309
|
+
recent_events.sort(key=lambda item: int(item.get("timestamp", 0)), reverse=True)
|
|
310
|
+
for event in recent_events:
|
|
311
|
+
svc = event.get("service", "unknown")
|
|
312
|
+
prev = event.get("previous_status", "unknown")
|
|
313
|
+
curr = event.get("status", "unknown")
|
|
314
|
+
stage = event.get("stage", "")
|
|
315
|
+
message = event.get("message", "")
|
|
316
|
+
track_file = event.get("track_file", "")
|
|
317
|
+
console.print(f" [cyan]{svc}[/cyan]: {prev} -> {curr} ({stage})")
|
|
318
|
+
if message:
|
|
319
|
+
console.print(f" message: {message}")
|
|
320
|
+
if track_file:
|
|
321
|
+
console.print(f" track: {track_file}")
|
|
322
|
+
|
|
246
323
|
|
|
247
324
|
@app.command()
|
|
248
325
|
def init(
|
|
@@ -150,7 +150,11 @@ def validate_config(raw: dict) -> WupConfig:
|
|
|
150
150
|
smoke_scenario=testql_raw.get("smoke_scenario", "smoke.testql.toon.yaml"),
|
|
151
151
|
output_format=testql_raw.get("output_format", "json"),
|
|
152
152
|
extra_args=testql_raw.get("extra_args", ["--timeout 10s"]),
|
|
153
|
-
endpoint_discovery=testql_raw.get("endpoint_discovery", True)
|
|
153
|
+
endpoint_discovery=testql_raw.get("endpoint_discovery", True),
|
|
154
|
+
base_url=testql_raw.get("base_url", ""),
|
|
155
|
+
base_url_env=testql_raw.get("base_url_env", "WUP_BASE_URL"),
|
|
156
|
+
explicit_endpoints=testql_raw.get("explicit_endpoints", []),
|
|
157
|
+
endpoints_by_service=testql_raw.get("endpoints_by_service", {})
|
|
154
158
|
)
|
|
155
159
|
|
|
156
160
|
return WupConfig(
|
|
@@ -215,7 +219,12 @@ def save_config(config: WupConfig, output_path: Path):
|
|
|
215
219
|
"scenario_dir": config.testql.scenario_dir,
|
|
216
220
|
"smoke_scenario": config.testql.smoke_scenario,
|
|
217
221
|
"output_format": config.testql.output_format,
|
|
218
|
-
"extra_args": config.testql.extra_args
|
|
222
|
+
"extra_args": config.testql.extra_args,
|
|
223
|
+
"endpoint_discovery": config.testql.endpoint_discovery,
|
|
224
|
+
"base_url": config.testql.base_url,
|
|
225
|
+
"base_url_env": config.testql.base_url_env,
|
|
226
|
+
"explicit_endpoints": config.testql.explicit_endpoints,
|
|
227
|
+
"endpoints_by_service": config.testql.endpoints_by_service,
|
|
219
228
|
}
|
|
220
229
|
}
|
|
221
230
|
|
|
@@ -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)
|
|
@@ -375,6 +383,26 @@ class WupWatcher:
|
|
|
375
383
|
await self.process_test_queue_once()
|
|
376
384
|
await asyncio.sleep(self.debounce_seconds)
|
|
377
385
|
|
|
386
|
+
def should_watch_file(self, file_path: str) -> bool:
|
|
387
|
+
"""
|
|
388
|
+
Check if a file should be watched based on configured file types.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
file_path: Path to the file
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
True if file should be watched, False otherwise
|
|
395
|
+
"""
|
|
396
|
+
normalized = str(file_path).lower()
|
|
397
|
+
if normalized.endswith(".testql.toon.yaml"):
|
|
398
|
+
return True
|
|
399
|
+
|
|
400
|
+
if not self.config.watch.file_types:
|
|
401
|
+
return True
|
|
402
|
+
|
|
403
|
+
file_suffix = Path(file_path).suffix.lower()
|
|
404
|
+
return file_suffix in self.config.watch.file_types
|
|
405
|
+
|
|
378
406
|
def on_file_change(self, file_path: str):
|
|
379
407
|
"""
|
|
380
408
|
Handle file change event.
|
|
@@ -382,6 +410,10 @@ class WupWatcher:
|
|
|
382
410
|
Args:
|
|
383
411
|
file_path: Path to the changed file
|
|
384
412
|
"""
|
|
413
|
+
# Check file type filter
|
|
414
|
+
if not self.should_watch_file(file_path):
|
|
415
|
+
return
|
|
416
|
+
|
|
385
417
|
# Only watch relevant directories
|
|
386
418
|
rel_path = self._to_relative_path(file_path)
|
|
387
419
|
parts = rel_path.parts
|
|
@@ -59,6 +59,10 @@ class TestQLConfig:
|
|
|
59
59
|
output_format: str = "json"
|
|
60
60
|
extra_args: List[str] = field(default_factory=lambda: ["--timeout 10s"])
|
|
61
61
|
endpoint_discovery: bool = True # Enable automatic endpoint discovery from scenarios
|
|
62
|
+
base_url: str = ""
|
|
63
|
+
base_url_env: str = "WUP_BASE_URL"
|
|
64
|
+
explicit_endpoints: List[str] = field(default_factory=list)
|
|
65
|
+
endpoints_by_service: Dict[str, List[str]] = field(default_factory=dict)
|
|
62
66
|
|
|
63
67
|
|
|
64
68
|
@dataclass
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import json
|
|
7
|
+
import os
|
|
7
8
|
import re
|
|
8
9
|
import subprocess
|
|
9
10
|
import time
|
|
@@ -50,10 +51,12 @@ class TestQLWatcher(WupWatcher):
|
|
|
50
51
|
|
|
51
52
|
__test__ = False
|
|
52
53
|
|
|
54
|
+
_UNSET = object()
|
|
55
|
+
|
|
53
56
|
def __init__(
|
|
54
57
|
self,
|
|
55
58
|
project_root: str,
|
|
56
|
-
scenarios_dir
|
|
59
|
+
scenarios_dir=_UNSET,
|
|
57
60
|
testql_bin: str = "testql",
|
|
58
61
|
track_dir: str = ".wup/tracks",
|
|
59
62
|
browser_service_url: Optional[str] = None,
|
|
@@ -68,16 +71,15 @@ class TestQLWatcher(WupWatcher):
|
|
|
68
71
|
# Pass config to parent class
|
|
69
72
|
super().__init__(project_root=project_root, config=config, **kwargs)
|
|
70
73
|
|
|
71
|
-
#
|
|
72
|
-
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:
|
|
73
78
|
self.scenarios_dir = self.project_root / config.testql.scenario_dir
|
|
74
|
-
self.testql_bin = testql_bin # CLI parameter takes precedence
|
|
75
|
-
# Use extra_args from config if needed
|
|
76
|
-
self.testql_extra_args = config.testql.extra_args
|
|
77
79
|
else:
|
|
78
|
-
self.scenarios_dir = self.project_root /
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
self.scenarios_dir = self.project_root / "testql-scenarios"
|
|
81
|
+
self.testql_bin = testql_bin
|
|
82
|
+
self.testql_extra_args = config.testql.extra_args if config and config.testql else []
|
|
81
83
|
|
|
82
84
|
self.quick_limit = quick_limit
|
|
83
85
|
self.track_dir = self.project_root / track_dir
|
|
@@ -87,12 +89,119 @@ class TestQLWatcher(WupWatcher):
|
|
|
87
89
|
events_file=self.project_root / ".wup" / "browser-events" / "latest.json",
|
|
88
90
|
)
|
|
89
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()
|
|
90
96
|
self.config = config
|
|
91
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
|
+
|
|
92
165
|
def _tokenize_service(self, service: str) -> List[str]:
|
|
93
166
|
raw_tokens = re.split(r"[^a-zA-Z0-9]+", service.lower())
|
|
94
167
|
return [token for token in raw_tokens if len(token) >= 3]
|
|
95
168
|
|
|
169
|
+
def _get_config_endpoints_for_service(self, service: str) -> List[str]:
|
|
170
|
+
by_service = self.config.testql.endpoints_by_service or {}
|
|
171
|
+
explicit = self.config.testql.explicit_endpoints or []
|
|
172
|
+
|
|
173
|
+
service_specific = by_service.get(service, [])
|
|
174
|
+
merged: List[str] = []
|
|
175
|
+
for endpoint in [*service_specific, *explicit]:
|
|
176
|
+
endpoint_url = self._to_full_url(endpoint)
|
|
177
|
+
if endpoint_url not in merged:
|
|
178
|
+
merged.append(endpoint_url)
|
|
179
|
+
return merged
|
|
180
|
+
|
|
181
|
+
def _resolve_base_url(self) -> str:
|
|
182
|
+
base_url = (self.config.testql.base_url or "").strip()
|
|
183
|
+
if base_url:
|
|
184
|
+
return base_url.rstrip("/")
|
|
185
|
+
|
|
186
|
+
env_key = (self.config.testql.base_url_env or "WUP_BASE_URL").strip()
|
|
187
|
+
env_url = os.getenv(env_key, "").strip()
|
|
188
|
+
if env_url:
|
|
189
|
+
return env_url.rstrip("/")
|
|
190
|
+
|
|
191
|
+
return ""
|
|
192
|
+
|
|
193
|
+
def _to_full_url(self, endpoint: str) -> str:
|
|
194
|
+
if endpoint.startswith("http://") or endpoint.startswith("https://"):
|
|
195
|
+
return endpoint
|
|
196
|
+
|
|
197
|
+
base_url = self._resolve_base_url()
|
|
198
|
+
if not base_url:
|
|
199
|
+
return endpoint
|
|
200
|
+
|
|
201
|
+
if endpoint.startswith("/"):
|
|
202
|
+
return f"{base_url}{endpoint}"
|
|
203
|
+
return f"{base_url}/{endpoint}"
|
|
204
|
+
|
|
96
205
|
def _discover_scenarios(self) -> List[Path]:
|
|
97
206
|
if not self.scenarios_dir.exists():
|
|
98
207
|
return []
|
|
@@ -210,6 +319,11 @@ class TestQLWatcher(WupWatcher):
|
|
|
210
319
|
return track_path
|
|
211
320
|
|
|
212
321
|
async def run_quick_test(self, service: str, endpoints: List[str]) -> bool:
|
|
322
|
+
merged_endpoints = list(endpoints)
|
|
323
|
+
for configured_endpoint in self._get_config_endpoints_for_service(service):
|
|
324
|
+
if configured_endpoint not in merged_endpoints:
|
|
325
|
+
merged_endpoints.append(configured_endpoint)
|
|
326
|
+
|
|
213
327
|
scenarios = self._select_scenarios_for_service(service)
|
|
214
328
|
|
|
215
329
|
# Apply service-specific quick limit
|
|
@@ -224,7 +338,7 @@ class TestQLWatcher(WupWatcher):
|
|
|
224
338
|
return True
|
|
225
339
|
|
|
226
340
|
self.console.print(
|
|
227
|
-
f"[cyan]🧪 Quick TestQL for {service} ({len(scenarios)} scenarios / {len(
|
|
341
|
+
f"[cyan]🧪 Quick TestQL for {service} ({len(scenarios)} scenarios / {len(merged_endpoints)} endpoints)[/cyan]"
|
|
228
342
|
)
|
|
229
343
|
|
|
230
344
|
for scenario in scenarios:
|
|
@@ -246,19 +360,38 @@ class TestQLWatcher(WupWatcher):
|
|
|
246
360
|
scenario=scenario,
|
|
247
361
|
result=result,
|
|
248
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
|
+
)
|
|
249
370
|
self.console.print(
|
|
250
371
|
f"[red]✗ Quick failed: {scenario.name} | track: {track_path}[/red]"
|
|
251
372
|
)
|
|
252
373
|
return False
|
|
253
374
|
|
|
375
|
+
self._record_health_transition(
|
|
376
|
+
service=service,
|
|
377
|
+
status="up",
|
|
378
|
+
stage="quick",
|
|
379
|
+
message="Quick TestQL passed",
|
|
380
|
+
)
|
|
254
381
|
self.console.print(f"[green]✓ Quick TestQL passed for {service}[/green]")
|
|
255
382
|
return True
|
|
256
383
|
|
|
257
384
|
async def run_detail_test(self, service: str, endpoints: List[str]) -> Dict:
|
|
385
|
+
merged_endpoints = list(endpoints)
|
|
386
|
+
for configured_endpoint in self._get_config_endpoints_for_service(service):
|
|
387
|
+
if configured_endpoint not in merged_endpoints:
|
|
388
|
+
merged_endpoints.append(configured_endpoint)
|
|
389
|
+
|
|
258
390
|
scenarios = self._select_scenarios_for_service(service)
|
|
259
391
|
results = {
|
|
260
392
|
"service": service,
|
|
261
393
|
"total_scenarios": len(scenarios),
|
|
394
|
+
"total_endpoints": len(merged_endpoints),
|
|
262
395
|
"passed": 0,
|
|
263
396
|
"failed": 0,
|
|
264
397
|
"failed_scenarios": [],
|
|
@@ -266,7 +399,7 @@ class TestQLWatcher(WupWatcher):
|
|
|
266
399
|
}
|
|
267
400
|
|
|
268
401
|
self.console.print(
|
|
269
|
-
f"[cyan]🔍 Detail TestQL for {service} ({len(scenarios)} scenarios / {len(
|
|
402
|
+
f"[cyan]🔍 Detail TestQL for {service} ({len(scenarios)} scenarios / {len(merged_endpoints)} endpoints)[/cyan]"
|
|
270
403
|
)
|
|
271
404
|
|
|
272
405
|
for scenario in scenarios:
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wup
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.11
|
|
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.8000 (12 commits)
|
|
36
|
+
- 👤 **Human dev:** ~$290 (2.9h @ $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,89 +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 / "api-users-failing.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
|
-
empty_config = WupConfig(
|
|
25
|
-
project=ProjectConfig(name="test"),
|
|
26
|
-
services=[],
|
|
27
|
-
test_strategy=None,
|
|
28
|
-
testql=None
|
|
29
|
-
)
|
|
30
|
-
watcher = TestQLWatcher(
|
|
31
|
-
project_root=str(root),
|
|
32
|
-
deps_file=str(root / "deps.json"),
|
|
33
|
-
scenarios_dir="testql-scenarios",
|
|
34
|
-
track_dir=".wup/tracks",
|
|
35
|
-
config=empty_config,
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
watcher.dependency_mapper.service_to_endpoints["app/users"] = ["/api/v1/users"]
|
|
39
|
-
|
|
40
|
-
def fake_run_testql(args, timeout):
|
|
41
|
-
if "--dry-run" in args:
|
|
42
|
-
return CompletedProcess(args=args, returncode=1, stdout="", stderr="intentional failure")
|
|
43
|
-
return CompletedProcess(args=args, returncode=0, stdout="{}", stderr="")
|
|
44
|
-
|
|
45
|
-
watcher._run_testql = fake_run_testql # type: ignore[method-assign]
|
|
46
|
-
|
|
47
|
-
result = asyncio.run(watcher.process_changed_file_once(str(app_file)))
|
|
48
|
-
|
|
49
|
-
assert result["processed_items"] >= 1
|
|
50
|
-
assert result["last_track_path"] is not None
|
|
51
|
-
|
|
52
|
-
track_path = Path(result["last_track_path"])
|
|
53
|
-
assert track_path.exists()
|
|
54
|
-
|
|
55
|
-
track_payload = json.loads(track_path.read_text(encoding="utf-8"))
|
|
56
|
-
assert track_payload["service"] == "app/users"
|
|
57
|
-
assert track_payload["stage"] == "quick"
|
|
58
|
-
assert "intentional failure" in track_payload["stderr_head"]
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def test_browser_event_file_is_written_without_service_url():
|
|
62
|
-
with tempfile.TemporaryDirectory() as tmpdir:
|
|
63
|
-
root = Path(tmpdir)
|
|
64
|
-
scenario_dir = root / "testql-scenarios"
|
|
65
|
-
scenario_dir.mkdir(parents=True, exist_ok=True)
|
|
66
|
-
scenario_file = scenario_dir / "api-users-smoke.testql.toon.yaml"
|
|
67
|
-
scenario_file.write_text("name: smoke\n", encoding="utf-8")
|
|
68
|
-
|
|
69
|
-
watcher = TestQLWatcher(
|
|
70
|
-
project_root=str(root),
|
|
71
|
-
deps_file=str(root / "deps.json"),
|
|
72
|
-
scenarios_dir="testql-scenarios",
|
|
73
|
-
track_dir=".wup/tracks",
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
result = CompletedProcess(args=["testql", "run"], returncode=1, stdout="", stderr="boom")
|
|
77
|
-
track_path = watcher._write_track(
|
|
78
|
-
service="app/users",
|
|
79
|
-
stage="quick",
|
|
80
|
-
scenario=scenario_file,
|
|
81
|
-
result=result,
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
assert track_path.exists()
|
|
85
|
-
event_file = root / ".wup" / "browser-events" / "latest.json"
|
|
86
|
-
assert event_file.exists()
|
|
87
|
-
event_payload = json.loads(event_file.read_text(encoding="utf-8"))
|
|
88
|
-
assert event_payload["type"] == "wup_testql_error"
|
|
89
|
-
assert event_payload["service"] == "app/users"
|
{wup-0.2.8 → wup-0.2.11}/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
|