wup 0.2.72__tar.gz → 0.2.74__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.72/wup.egg-info → wup-0.2.74}/PKG-INFO +6 -6
- {wup-0.2.72 → wup-0.2.74}/README.md +5 -5
- {wup-0.2.72 → wup-0.2.74}/pyproject.toml +1 -1
- wup-0.2.74/tests/test_aql.py +120 -0
- wup-0.2.74/tests/test_discovery_adapters.py +118 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_genericity.py +25 -0
- wup-0.2.74/tests/test_oql.py +104 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_testql_monitor.py +7 -2
- {wup-0.2.72 → wup-0.2.74}/wup/__init__.py +1 -1
- wup-0.2.74/wup/aql.py +306 -0
- {wup-0.2.72 → wup-0.2.74}/wup/cli.py +105 -0
- {wup-0.2.72 → wup-0.2.74}/wup/config.py +2 -0
- wup-0.2.74/wup/dependency_mapper.py +177 -0
- {wup-0.2.72 → wup-0.2.74}/wup/discovery.py +15 -7
- {wup-0.2.72 → wup-0.2.74}/wup/models/config.py +4 -0
- wup-0.2.74/wup/oql.py +267 -0
- {wup-0.2.72 → wup-0.2.74}/wup/testql_monitor.py +40 -33
- {wup-0.2.72 → wup-0.2.74/wup.egg-info}/PKG-INFO +6 -6
- {wup-0.2.72 → wup-0.2.74}/wup.egg-info/SOURCES.txt +5 -0
- wup-0.2.72/wup/dependency_mapper.py +0 -301
- {wup-0.2.72 → wup-0.2.74}/LICENSE +0 -0
- {wup-0.2.72 → wup-0.2.74}/setup.cfg +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_assistant.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_auto_detection.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_cli_bridge.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_cli_filtering.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_control.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_e2e.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_endpoints_init_cli.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_health_summary_passed.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_monitoring_manifest.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_multi_project.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_planfile_reporter_dedupe.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_probe_mutex.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_service_inference.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_status_data.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_sync.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_testql_watcher.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_visual_diff_periodic_skip.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_visual_diff_progress.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_watch_exclude.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_watch_no_paths_exit.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_web_client.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_wup.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/tests/test_wup_generate.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/_ast_detector.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/_base_detector.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/_hash_detector.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/_yaml_detector.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/anomaly_detector.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/anomaly_models.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/assistant.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/assistant_discovery.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/assistant_validator.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/bus.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/cli_bridge.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/cli_config_generator.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/cli_scanner.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/control.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/core.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/endpoints.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/event_store.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/file_watcher/events/file_events.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/generate.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/init_cli.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/models/__init__.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/models/target.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/monitoring_manifest.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/multi.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/paths.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/planfile_reporter.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/status_data.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/sync.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/testing/events/health_events.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/testing/events/test_results.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/testing/handlers/event_handlers.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/testing/handlers/health_handlers.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/testing/queries/health_queries.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/testql_cli_generator.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/testql_discovery.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/testql_watcher.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/validate.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/visual_diff.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup/web_client.py +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.72 → wup-0.2.74}/wup.egg-info/requires.txt +0 -0
- {wup-0.2.72 → wup-0.2.74}/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.
|
|
3
|
+
Version: 0.2.74
|
|
4
4
|
Summary: WUP (What's Up) - Intelligent file watcher for regression testing in large projects
|
|
5
5
|
Author-email: Tom Sapletta <tom@sapletta.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -42,17 +42,17 @@ Dynamic: license-file
|
|
|
42
42
|
|
|
43
43
|
## AI Cost Tracking
|
|
44
44
|
|
|
45
|
-
    
|
|
46
|
+
  
|
|
47
47
|
|
|
48
|
-
- 🤖 **LLM usage:** $7.
|
|
49
|
-
- 👤 **Human dev:** ~$
|
|
48
|
+
- 🤖 **LLM usage:** $7.1945 (102 commits)
|
|
49
|
+
- 👤 **Human dev:** ~$5242 (52.4h @ $100/h, 30min dedup)
|
|
50
50
|
|
|
51
51
|
Generated on 2026-07-17 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
52
52
|
|
|
53
53
|
---
|
|
54
54
|
|
|
55
|
-
    
|
|
56
56
|
|
|
57
57
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
58
58
|
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
    
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $7.
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $7.1945 (102 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$5242 (52.4h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
12
|
Generated on 2026-07-17 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
    
|
|
17
17
|
|
|
18
18
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
19
19
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Tests for AQL — the Assertion Query Language."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from wup.aql import AQLEngine, AQLError, CheckAQL, parse_rule, register_aql
|
|
11
|
+
from wup.bus import EventBus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _sample(tmp_path: Path) -> Path:
|
|
15
|
+
f = tmp_path / "sample.json"
|
|
16
|
+
f.write_text(json.dumps({
|
|
17
|
+
"name": "subactor",
|
|
18
|
+
"version": "1.2.0",
|
|
19
|
+
"services": ["a", "b"],
|
|
20
|
+
"testql": {"probe_interval_s": 60},
|
|
21
|
+
"flag": True,
|
|
22
|
+
"empty": None,
|
|
23
|
+
}))
|
|
24
|
+
return f
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _check(tmp_path: Path, rule: str) -> bool:
|
|
28
|
+
"""True when the rule passes (no violations)."""
|
|
29
|
+
return not AQLEngine(tmp_path).check_file(_sample(tmp_path), [rule])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# --- parsing --------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def test_parse_exists() -> None:
|
|
35
|
+
r = parse_rule("json .version exists")
|
|
36
|
+
assert (r.selector, r.path, r.op) == ("json", ".version", "exists")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_parse_length_and_severity() -> None:
|
|
40
|
+
r = parse_rule("json .services length > 0 severity high")
|
|
41
|
+
assert (r.op, r.length_op, r.value, r.severity) == ("length", ">", "0", "high")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.parametrize("bad", [
|
|
45
|
+
"", "widget .x exists", "json", "json .x", "json .x bogus",
|
|
46
|
+
"json .x length foo", "json .x type banana", "json .x = ",
|
|
47
|
+
"json .x severity nope",
|
|
48
|
+
])
|
|
49
|
+
def test_parse_errors(bad: str) -> None:
|
|
50
|
+
with pytest.raises(AQLError):
|
|
51
|
+
parse_rule(bad)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --- evaluation -----------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
@pytest.mark.parametrize("rule", [
|
|
57
|
+
"json .version exists",
|
|
58
|
+
"json .missing missing",
|
|
59
|
+
"json .services length > 0",
|
|
60
|
+
"json .services length = 2",
|
|
61
|
+
"json .testql.probe_interval_s >= 60",
|
|
62
|
+
"json .name = subactor",
|
|
63
|
+
"json .name ~ sub",
|
|
64
|
+
"json .name !~ zzz",
|
|
65
|
+
"json .version matches ^\\d+\\.\\d+",
|
|
66
|
+
"json .services type array",
|
|
67
|
+
"json .testql type object",
|
|
68
|
+
"json .flag type bool",
|
|
69
|
+
"json .name type string",
|
|
70
|
+
"json .testql.probe_interval_s type number",
|
|
71
|
+
"keys .testql ~ probe_interval_s",
|
|
72
|
+
"keys .testql length = 1",
|
|
73
|
+
"text ~ subactor",
|
|
74
|
+
])
|
|
75
|
+
def test_passing_rules(tmp_path: Path, rule: str) -> None:
|
|
76
|
+
assert _check(tmp_path, rule)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize("rule", [
|
|
80
|
+
"json .missing exists",
|
|
81
|
+
"json .version missing",
|
|
82
|
+
"json .services length > 5",
|
|
83
|
+
"json .name = other",
|
|
84
|
+
"json .name ~ zzz",
|
|
85
|
+
"json .version matches ^zzz",
|
|
86
|
+
"json .services type object",
|
|
87
|
+
"json .missing type string",
|
|
88
|
+
"keys .testql ~ nope",
|
|
89
|
+
"text ~ nonexistent-token",
|
|
90
|
+
])
|
|
91
|
+
def test_failing_rules(tmp_path: Path, rule: str) -> None:
|
|
92
|
+
assert not _check(tmp_path, rule)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_violation_carries_severity(tmp_path: Path) -> None:
|
|
96
|
+
v = AQLEngine(tmp_path).check_file(_sample(tmp_path), ["json .missing exists severity critical"])
|
|
97
|
+
assert len(v) == 1 and v[0].severity == "critical" and v[0].detector == "aql"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_nested_and_indexed_paths(tmp_path: Path) -> None:
|
|
101
|
+
assert _check(tmp_path, "json .services[0] = a")
|
|
102
|
+
assert _check(tmp_path, "json .services[1] = b")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_missing_file(tmp_path: Path) -> None:
|
|
106
|
+
v = AQLEngine(tmp_path).check_file(tmp_path / "nope.json", ["json .x exists"])
|
|
107
|
+
assert len(v) == 1 and v[0].anomaly_type == "error"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_yaml_file(tmp_path: Path) -> None:
|
|
111
|
+
y = tmp_path / "wup.yaml"
|
|
112
|
+
y.write_text("project:\n name: demo\ntestql:\n probe_interval_s: 0\n")
|
|
113
|
+
assert not AQLEngine(tmp_path).check_file(y, ["yaml .project.name = demo", "yaml .testql.probe_interval_s >= 0"])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_bus_integration(tmp_path: Path) -> None:
|
|
117
|
+
bus = EventBus()
|
|
118
|
+
register_aql(bus, tmp_path)
|
|
119
|
+
result = bus.query(CheckAQL(file=str(_sample(tmp_path)), rules=["json .version exists"]))
|
|
120
|
+
assert result == [] # no violations
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Tests for the pluggable endpoint-discovery adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from wup.dependency_mapper import DependencyMapper
|
|
10
|
+
from wup.discovery import SourceIndex, detect_frameworks, discover_endpoints
|
|
11
|
+
|
|
12
|
+
# (framework, filename, source) fixtures — one per adapter.
|
|
13
|
+
FIXTURES = {
|
|
14
|
+
"fastapi": (
|
|
15
|
+
"api.py",
|
|
16
|
+
"from fastapi import APIRouter\nrouter = APIRouter()\n"
|
|
17
|
+
'@router.get("/users")\ndef u(): ...\n@router.post("/users")\ndef c(): ...\n',
|
|
18
|
+
{"/users"},
|
|
19
|
+
),
|
|
20
|
+
"flask": (
|
|
21
|
+
"views.py",
|
|
22
|
+
"from flask import Blueprint\nbp = Blueprint('a', __name__)\n"
|
|
23
|
+
'@bp.route("/login")\ndef login(): ...\n',
|
|
24
|
+
{"/login"},
|
|
25
|
+
),
|
|
26
|
+
"django": (
|
|
27
|
+
"urls.py",
|
|
28
|
+
'from django.urls import path\nurlpatterns = [path("admin/", v), path("home/", v)]\n',
|
|
29
|
+
{"admin/", "home/"},
|
|
30
|
+
),
|
|
31
|
+
"nestjs": (
|
|
32
|
+
"users.controller.ts",
|
|
33
|
+
"import { Controller, Get } from '@nestjs/common';\n"
|
|
34
|
+
"@Controller('users')\nclass C { @Get('/list') l(){} @Post('/create') c(){} }\n",
|
|
35
|
+
{"/list", "/create"},
|
|
36
|
+
),
|
|
37
|
+
"express": (
|
|
38
|
+
"routes.js",
|
|
39
|
+
"const express = require('express')\nconst router = express.Router()\n"
|
|
40
|
+
"router.get('/api/users', h)\nrouter.post('/api/users', h)\n",
|
|
41
|
+
{"/api/users"},
|
|
42
|
+
),
|
|
43
|
+
"fastify": (
|
|
44
|
+
"server.ts",
|
|
45
|
+
"import Fastify from 'fastify'\nconst f = Fastify()\n"
|
|
46
|
+
"f.get('/health', h)\nf.post('/items', h)\n",
|
|
47
|
+
{"/health", "/items"},
|
|
48
|
+
),
|
|
49
|
+
"hono": (
|
|
50
|
+
"app.ts",
|
|
51
|
+
"import { Hono } from 'hono'\nconst app = new Hono()\n"
|
|
52
|
+
"app.get('/ping', c)\napp.delete('/items/:id', c)\n",
|
|
53
|
+
{"/ping", "/items/:id"},
|
|
54
|
+
),
|
|
55
|
+
"go": (
|
|
56
|
+
"main.go",
|
|
57
|
+
'package main\nimport "github.com/gin-gonic/gin"\n'
|
|
58
|
+
'func main(){ r := gin.Default(); r.GET("/ping", h); r.POST("/users", h) }\n',
|
|
59
|
+
{"/ping", "/users"},
|
|
60
|
+
),
|
|
61
|
+
"openapi": (
|
|
62
|
+
"openapi.yaml",
|
|
63
|
+
"openapi: 3.0.0\npaths:\n /pets:\n get: {}\n post: {}\n /pets/{id}:\n delete: {}\n",
|
|
64
|
+
{"/pets", "/pets/{id}"},
|
|
65
|
+
),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _write(tmp_path: Path, filename: str, source: str) -> Path:
|
|
70
|
+
svc = tmp_path / "services" / "svc"
|
|
71
|
+
svc.mkdir(parents=True)
|
|
72
|
+
(svc / filename).write_text(source)
|
|
73
|
+
return tmp_path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.parametrize("framework", list(FIXTURES))
|
|
77
|
+
def test_adapter_detects_and_discovers(tmp_path: Path, framework: str) -> None:
|
|
78
|
+
filename, source, expected_paths = FIXTURES[framework]
|
|
79
|
+
root = _write(tmp_path, filename, source)
|
|
80
|
+
|
|
81
|
+
assert framework in detect_frameworks(SourceIndex(root))
|
|
82
|
+
|
|
83
|
+
paths = {ep.path for ep in discover_endpoints(root, framework)}
|
|
84
|
+
assert expected_paths <= paths
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.parametrize("framework", list(FIXTURES))
|
|
88
|
+
def test_mapper_builds_nonempty_deps(tmp_path: Path, framework: str) -> None:
|
|
89
|
+
filename, source, expected_paths = FIXTURES[framework]
|
|
90
|
+
root = _write(tmp_path, filename, source)
|
|
91
|
+
|
|
92
|
+
result = DependencyMapper(str(root)).build_from_codebase()
|
|
93
|
+
all_endpoints = {ep for svc in result["services"].values() for ep in svc["endpoints"]}
|
|
94
|
+
assert expected_paths <= all_endpoints
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_auto_mode_prefers_specific_framework(tmp_path: Path) -> None:
|
|
98
|
+
"""A Hono app using app.get() attributes to hono, not express."""
|
|
99
|
+
root = _write(tmp_path, "app.ts", "import { Hono } from 'hono'\nconst app = new Hono()\napp.get('/x', c)\n")
|
|
100
|
+
assert DependencyMapper(str(root))._detect_framework() == "hono"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_no_endpoints_for_plain_project(tmp_path: Path) -> None:
|
|
104
|
+
(tmp_path / "readme.md").write_text("# hello")
|
|
105
|
+
result = DependencyMapper(str(tmp_path)).build_from_codebase()
|
|
106
|
+
assert result == {"services": {}, "files": {}}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_endpoints_deduplicated(tmp_path: Path) -> None:
|
|
110
|
+
"""The same path via GET and POST collapses to a single endpoint entry."""
|
|
111
|
+
root = _write(
|
|
112
|
+
tmp_path, "api.py",
|
|
113
|
+
"from fastapi import APIRouter\nrouter = APIRouter()\n"
|
|
114
|
+
'@router.get("/x")\ndef a(): ...\n@router.post("/x")\ndef b(): ...\n',
|
|
115
|
+
)
|
|
116
|
+
result = DependencyMapper(str(root)).build_from_codebase()
|
|
117
|
+
eps = result["services"]["services/svc"]["endpoints"]
|
|
118
|
+
assert eps.count("/x") == 1
|
|
@@ -71,6 +71,31 @@ def test_docker_map_generic_token_match() -> None:
|
|
|
71
71
|
assert _map_docker_to_wup_service(_dc("payments-api"), services) == "payments"
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
def test_reject_prefixes_generic_by_default() -> None:
|
|
75
|
+
from wup.testql_monitor import ProbeTarget, is_monitoring_probe, reject_prefixes_for_config
|
|
76
|
+
|
|
77
|
+
cfg = validate_config({"project": {"name": "p"}})
|
|
78
|
+
assert reject_prefixes_for_config(cfg) == ()
|
|
79
|
+
# A generic /api/... health path is a valid probe (not fleet-rejected).
|
|
80
|
+
assert is_monitoring_probe(ProbeTarget(url="http://x:8100/api/id/health"), reject_prefixes_for_config(cfg))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_reject_prefixes_connect_profile_opt_in() -> None:
|
|
84
|
+
from wup.testql_monitor import ProbeTarget, is_monitoring_probe, reject_prefixes_for_config
|
|
85
|
+
|
|
86
|
+
cfg = validate_config({"project": {"name": "p"}, "testql": {"service_map_profile": "connect"}})
|
|
87
|
+
prefixes = reject_prefixes_for_config(cfg)
|
|
88
|
+
assert "/api/id" in prefixes
|
|
89
|
+
assert not is_monitoring_probe(ProbeTarget(url="http://x:8100/api/id/health"), prefixes)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_reject_prefixes_explicit_override() -> None:
|
|
93
|
+
from wup.testql_monitor import reject_prefixes_for_config
|
|
94
|
+
|
|
95
|
+
cfg = validate_config({"project": {"name": "p"}, "testql": {"monitoring_reject_prefixes": ["/internal"]}})
|
|
96
|
+
assert reject_prefixes_for_config(cfg) == ("/internal",)
|
|
97
|
+
|
|
98
|
+
|
|
74
99
|
def test_config_roundtrips_docker_service_map() -> None:
|
|
75
100
|
raw = {
|
|
76
101
|
"project": {"name": "p"},
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Tests for OQL — the Observability Query Language."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from wup.bus import EventBus
|
|
12
|
+
from wup.oql import Condition, OQLEngine, OQLError, RunOQL, parse, register_oql
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _project(tmp_path: Path) -> Path:
|
|
16
|
+
now = int(time.time())
|
|
17
|
+
wup = tmp_path / ".wup"
|
|
18
|
+
wup.mkdir()
|
|
19
|
+
(wup / "service-health.json").write_text(json.dumps({
|
|
20
|
+
"api": {"status": "down", "updated_at": now, "stage": "probe", "message": "500 error"},
|
|
21
|
+
"web": {"status": "up", "updated_at": now, "stage": "probe", "message": ""},
|
|
22
|
+
"worker": {"status": "degraded", "updated_at": now - 500, "stage": "quick", "message": "slow"},
|
|
23
|
+
}))
|
|
24
|
+
(wup / "service-health-events.jsonl").write_text(
|
|
25
|
+
json.dumps({"service": "api", "status": "down", "timestamp": now}) + "\n"
|
|
26
|
+
+ json.dumps({"service": "web", "status": "up", "timestamp": now - 4000}) + "\n"
|
|
27
|
+
)
|
|
28
|
+
return tmp_path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --- parsing --------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def test_parse_minimal() -> None:
|
|
34
|
+
q = parse("services")
|
|
35
|
+
assert q.source == "services" and q.conditions == [] and q.limit is None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_parse_full() -> None:
|
|
39
|
+
q = parse("events where service = api and status != up since 1h limit 5")
|
|
40
|
+
assert q.source == "events"
|
|
41
|
+
assert q.conditions == [Condition("service", "=", "api"), Condition("status", "!=", "up")]
|
|
42
|
+
assert q.since_seconds == 3600
|
|
43
|
+
assert q.limit == 5
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_parse_operator_without_spaces() -> None:
|
|
47
|
+
q = parse("services where status=down")
|
|
48
|
+
assert q.conditions == [Condition("status", "=", "down")]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize("bad", [
|
|
52
|
+
"", "widgets", "services where", "services where status", "services since",
|
|
53
|
+
"services limit x", "services since 5x",
|
|
54
|
+
])
|
|
55
|
+
def test_parse_errors(bad: str) -> None:
|
|
56
|
+
with pytest.raises(OQLError):
|
|
57
|
+
parse(bad)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- execution ------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def test_filter_equals(tmp_path: Path) -> None:
|
|
63
|
+
engine = OQLEngine(_project(tmp_path))
|
|
64
|
+
rows = engine.execute("services where status = down")
|
|
65
|
+
assert [r["name"] for r in rows] == ["api"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_filter_not_equals(tmp_path: Path) -> None:
|
|
69
|
+
engine = OQLEngine(_project(tmp_path))
|
|
70
|
+
assert {r["name"] for r in engine.execute("services where status != up")} == {"api", "worker"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_contains_operator(tmp_path: Path) -> None:
|
|
74
|
+
engine = OQLEngine(_project(tmp_path))
|
|
75
|
+
assert [r["name"] for r in engine.execute("services where message ~ error")] == ["api"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_since_filters_old_events(tmp_path: Path) -> None:
|
|
79
|
+
engine = OQLEngine(_project(tmp_path))
|
|
80
|
+
rows = engine.execute("events since 10m")
|
|
81
|
+
assert [r["service"] for r in rows] == ["api"] # web event (4000s old) excluded
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_limit(tmp_path: Path) -> None:
|
|
85
|
+
engine = OQLEngine(_project(tmp_path))
|
|
86
|
+
assert len(engine.execute("services limit 1")) == 1
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_numeric_comparison(tmp_path: Path) -> None:
|
|
90
|
+
engine = OQLEngine(_project(tmp_path))
|
|
91
|
+
now = int(time.time())
|
|
92
|
+
rows = engine.execute(f"services where updated_at > {now - 100}")
|
|
93
|
+
assert {r["name"] for r in rows} == {"api", "web"} # worker is 500s old
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_missing_files_return_empty(tmp_path: Path) -> None:
|
|
97
|
+
assert OQLEngine(tmp_path).execute("services") == []
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_bus_integration(tmp_path: Path) -> None:
|
|
101
|
+
bus = EventBus()
|
|
102
|
+
register_oql(bus, _project(tmp_path))
|
|
103
|
+
rows = bus.query(RunOQL(query="services where status = down"))
|
|
104
|
+
assert [r["name"] for r in rows] == ["api"]
|
|
@@ -78,9 +78,14 @@ def test_firmware_plugin_health_catalog_not_periodic_live_probe():
|
|
|
78
78
|
)
|
|
79
79
|
|
|
80
80
|
|
|
81
|
-
def
|
|
81
|
+
def test_connect_api_paths_rejection_is_opt_in():
|
|
82
|
+
from wup.testql_monitor import _BUILTIN_REJECT_PREFIXES
|
|
83
|
+
|
|
82
84
|
probe = ProbeTarget(url="http://localhost:8100/api/id/health")
|
|
83
|
-
|
|
85
|
+
# Generic default: nothing is rejected — /api/id/health is a valid probe.
|
|
86
|
+
assert is_monitoring_probe(probe)
|
|
87
|
+
# With the connect profile's reject-prefixes, it is excluded.
|
|
88
|
+
assert not is_monitoring_probe(probe, _BUILTIN_REJECT_PREFIXES["connect"])
|
|
84
89
|
assert assign_probe_to_service(
|
|
85
90
|
probe,
|
|
86
91
|
[ServiceConfig(name="backend", paths=["backend/**", "api/**"])],
|
|
@@ -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.74"
|
|
11
11
|
__author__ = "Tom Sapletta"
|
|
12
12
|
|
|
13
13
|
from .config import load_config, save_config, get_default_config
|