ankole-framework 1.0.0__py3-none-any.whl
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.
- ankole/__init__.py +88 -0
- ankole/driver/__init__.py +42 -0
- ankole/driver/a11y.py +228 -0
- ankole/driver/api_driver.py +288 -0
- ankole/driver/api_mock.py +218 -0
- ankole/driver/appmanager.py +297 -0
- ankole/driver/base.py +140 -0
- ankole/driver/cli_driver.py +180 -0
- ankole/driver/config_validator.py +182 -0
- ankole/driver/console_runner.py +377 -0
- ankole/driver/db_driver.py +162 -0
- ankole/driver/evidence.py +359 -0
- ankole/driver/grafana_push.py +198 -0
- ankole/driver/health_check.py +213 -0
- ankole/driver/kiwi_tcms.py +817 -0
- ankole/driver/log_collector.py +488 -0
- ankole/driver/loki_collector.py +333 -0
- ankole/driver/remote_trigger.py +435 -0
- ankole/driver/smoke_gate.py +97 -0
- ankole/driver/ui_driver.py +1199 -0
- ankole/driver/visual.py +197 -0
- ankole/driver/web_driver.py +297 -0
- ankole/driver/window_monitor.py +232 -0
- ankole/driver/zap_scanner.py +205 -0
- ankole/flows/__init__.py +10 -0
- ankole/flows/base.py +243 -0
- ankole/flows/workspace/__init__.py +1 -0
- ankole/flows/workspace/member_management.py +96 -0
- ankole/flows/workspace/project_approval.py +81 -0
- ankole/pages/__init__.py +11 -0
- ankole/pages/base_page.py +135 -0
- ankole/pages/desktop/__init__.py +0 -0
- ankole/pages/web/__init__.py +5 -0
- ankole/pages/web/base_web_page.py +69 -0
- ankole/pages/web/dashboard_page.py +44 -0
- ankole/pages/web/login_page.py +48 -0
- ankole/pages/web/member_management_page.py +95 -0
- ankole/pages/web/project_approval_page.py +112 -0
- ankole/pages/web/role_management_page.py +42 -0
- ankole/plugin/__init__.py +39 -0
- ankole/plugin/config.py +223 -0
- ankole/plugin/fixtures.py +173 -0
- ankole/plugin/flaky_tracker.py +129 -0
- ankole/plugin/hooks.py +371 -0
- ankole/plugin/kiwi_hooks.py +158 -0
- ankole/plugin/metrics.py +49 -0
- ankole/steps/__init__.py +1 -0
- ankole/steps/workspace/__init__.py +1 -0
- ankole/steps/workspace/login.py +26 -0
- ankole/steps/workspace/member_management.py +107 -0
- ankole/steps/workspace/project_approval.py +68 -0
- ankole/testing/__init__.py +19 -0
- ankole/testing/conftest_factory.py +198 -0
- ankole/testing/conftest_hooks.py +95 -0
- ankole/testing/conftest_utils.py +64 -0
- ankole/testing/data_factory.py +262 -0
- ankole/testing/parallel.py +63 -0
- ankole/testing/security.py +254 -0
- ankole_framework-1.0.0.dist-info/METADATA +259 -0
- ankole_framework-1.0.0.dist-info/RECORD +64 -0
- ankole_framework-1.0.0.dist-info/WHEEL +5 -0
- ankole_framework-1.0.0.dist-info/entry_points.txt +2 -0
- ankole_framework-1.0.0.dist-info/licenses/LICENSE +21 -0
- ankole_framework-1.0.0.dist-info/top_level.txt +1 -0
ankole/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ankole Framework - Multi-driver E2E test framework.
|
|
3
|
+
|
|
4
|
+
Supports Playwright (web), pywinauto (desktop), httpx (API), and subprocess (CLI)
|
|
5
|
+
with full observability: Grafana, Prometheus, Loki, Allure, Kiwi TCMS.
|
|
6
|
+
|
|
7
|
+
Install:
|
|
8
|
+
pip install ankole-framework[all]
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
from ankole import WebDriver, APIDriver, UIDriver, ConsoleRunner, Evidence
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
|
|
16
|
+
__version__ = "2.0.0"
|
|
17
|
+
|
|
18
|
+
# Eagerly import cross-platform modules only
|
|
19
|
+
from ankole.driver.evidence import Evidence, StepTracker, tracked_step
|
|
20
|
+
from ankole.driver.console_runner import ConsoleRunner, CommandResult, resolve_platform_config
|
|
21
|
+
from ankole.driver.log_collector import LogCollector, LogMonitor
|
|
22
|
+
from ankole.driver.kiwi_tcms import KiwiReporter
|
|
23
|
+
from ankole.driver.grafana_push import MetricsPusher
|
|
24
|
+
from ankole.driver.health_check import HealthChecker, HealthCheckResult, HealthCheckReport
|
|
25
|
+
from ankole.driver.smoke_gate import SmokeGate
|
|
26
|
+
|
|
27
|
+
# Lazy import for platform-specific / optional-dep modules
|
|
28
|
+
_LAZY_IMPORTS = {
|
|
29
|
+
# Desktop (pywinauto)
|
|
30
|
+
"UIDriver": "ankole.driver.ui_driver",
|
|
31
|
+
"WindowMonitor": "ankole.driver.window_monitor",
|
|
32
|
+
"UIAppManager": "ankole.driver.appmanager",
|
|
33
|
+
# Web (Playwright)
|
|
34
|
+
"WebDriver": "ankole.driver.web_driver",
|
|
35
|
+
# API (httpx)
|
|
36
|
+
"APIDriver": "ankole.driver.api_driver",
|
|
37
|
+
# Infrastructure
|
|
38
|
+
"RemoteTrigger": "ankole.driver.remote_trigger",
|
|
39
|
+
"RemoteResult": "ankole.driver.remote_trigger",
|
|
40
|
+
"RemoteAgentPool": "ankole.driver.remote_trigger",
|
|
41
|
+
"LokiLogCollector": "ankole.driver.loki_collector",
|
|
42
|
+
"DriverProtocol": "ankole.driver.base",
|
|
43
|
+
"WebDriverProtocol": "ankole.driver.base",
|
|
44
|
+
"APIDriverProtocol": "ankole.driver.base",
|
|
45
|
+
"CLIDriver": "ankole.driver.cli_driver",
|
|
46
|
+
"ConfigValidator": "ankole.driver.config_validator",
|
|
47
|
+
"ConfigValidationError": "ankole.driver.config_validator",
|
|
48
|
+
"BasePage": "ankole.pages.base_page",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def __getattr__(name):
|
|
53
|
+
if name in _LAZY_IMPORTS:
|
|
54
|
+
module = importlib.import_module(_LAZY_IMPORTS[name])
|
|
55
|
+
return getattr(module, name)
|
|
56
|
+
raise AttributeError(f"module 'ankole' has no attribute {name!r}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
# Core driver infrastructure
|
|
61
|
+
"UIDriver",
|
|
62
|
+
"WebDriver",
|
|
63
|
+
"APIDriver",
|
|
64
|
+
"DriverProtocol",
|
|
65
|
+
"WebDriverProtocol",
|
|
66
|
+
"APIDriverProtocol",
|
|
67
|
+
"ConsoleRunner",
|
|
68
|
+
"CommandResult",
|
|
69
|
+
"resolve_platform_config",
|
|
70
|
+
"Evidence",
|
|
71
|
+
"StepTracker",
|
|
72
|
+
"tracked_step",
|
|
73
|
+
"LogCollector",
|
|
74
|
+
"LogMonitor",
|
|
75
|
+
"LokiLogCollector",
|
|
76
|
+
"KiwiReporter",
|
|
77
|
+
"MetricsPusher",
|
|
78
|
+
"HealthChecker",
|
|
79
|
+
"HealthCheckResult",
|
|
80
|
+
"HealthCheckReport",
|
|
81
|
+
"SmokeGate",
|
|
82
|
+
"WindowMonitor",
|
|
83
|
+
"RemoteTrigger",
|
|
84
|
+
"RemoteResult",
|
|
85
|
+
"RemoteAgentPool",
|
|
86
|
+
# Generic Page Object base
|
|
87
|
+
"BasePage",
|
|
88
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Driver modules — infrastructure, evidence, integrations.
|
|
2
|
+
|
|
3
|
+
Re-exports key classes for convenience::
|
|
4
|
+
|
|
5
|
+
from ankole.driver import UIDriver, Evidence, tracked_step
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ankole.driver.appmanager import UIAppManager
|
|
9
|
+
from ankole.driver.cli_driver import CLIDriver
|
|
10
|
+
from ankole.driver.config_validator import ConfigValidationError, ConfigValidator
|
|
11
|
+
from ankole.driver.console_runner import CommandResult, ConsoleRunner, resolve_platform_config
|
|
12
|
+
from ankole.driver.evidence import Evidence, StepTracker, tracked_step
|
|
13
|
+
from ankole.driver.grafana_push import MetricsPusher
|
|
14
|
+
from ankole.driver.health_check import HealthChecker, HealthCheckReport, HealthCheckResult
|
|
15
|
+
from ankole.driver.kiwi_tcms import KiwiReporter
|
|
16
|
+
from ankole.driver.log_collector import LogCollector, LogMonitor
|
|
17
|
+
from ankole.driver.smoke_gate import SmokeGate
|
|
18
|
+
from ankole.driver.zap_scanner import ZAPAlert, ZAPScanner, ZAPScanReport
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Evidence",
|
|
22
|
+
"StepTracker",
|
|
23
|
+
"tracked_step",
|
|
24
|
+
"ConsoleRunner",
|
|
25
|
+
"CommandResult",
|
|
26
|
+
"resolve_platform_config",
|
|
27
|
+
"LogCollector",
|
|
28
|
+
"LogMonitor",
|
|
29
|
+
"KiwiReporter",
|
|
30
|
+
"MetricsPusher",
|
|
31
|
+
"HealthChecker",
|
|
32
|
+
"HealthCheckResult",
|
|
33
|
+
"HealthCheckReport",
|
|
34
|
+
"SmokeGate",
|
|
35
|
+
"UIAppManager",
|
|
36
|
+
"CLIDriver",
|
|
37
|
+
"ConfigValidator",
|
|
38
|
+
"ConfigValidationError",
|
|
39
|
+
"ZAPScanner",
|
|
40
|
+
"ZAPAlert",
|
|
41
|
+
"ZAPScanReport",
|
|
42
|
+
]
|
ankole/driver/a11y.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Accessibility testing via axe-core injection.
|
|
2
|
+
|
|
3
|
+
Injects axe-core into Playwright pages and runs WCAG compliance scans::
|
|
4
|
+
|
|
5
|
+
scanner = A11yScanner()
|
|
6
|
+
report = scanner.scan(web_driver, tags=["wcag2a", "wcag2aa"])
|
|
7
|
+
report.assert_no_violations(impact=["critical", "serious"])
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
AXE_CDN_URL = "https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.9.1/axe.min.js"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class A11yViolation:
|
|
24
|
+
"""Single accessibility violation."""
|
|
25
|
+
|
|
26
|
+
rule_id: str
|
|
27
|
+
impact: str # "minor", "moderate", "serious", "critical"
|
|
28
|
+
description: str
|
|
29
|
+
help_url: str
|
|
30
|
+
nodes: list[dict[str, Any]] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def target_selectors(self) -> list[str]:
|
|
34
|
+
"""CSS selectors of affected elements."""
|
|
35
|
+
targets = []
|
|
36
|
+
for node in self.nodes:
|
|
37
|
+
for target in node.get("target", []):
|
|
38
|
+
if isinstance(target, list):
|
|
39
|
+
targets.extend(target)
|
|
40
|
+
else:
|
|
41
|
+
targets.append(target)
|
|
42
|
+
return targets
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class A11yReport:
|
|
47
|
+
"""Accessibility scan results."""
|
|
48
|
+
|
|
49
|
+
url: str
|
|
50
|
+
violations: list[A11yViolation] = field(default_factory=list)
|
|
51
|
+
passes: int = 0
|
|
52
|
+
inapplicable: int = 0
|
|
53
|
+
incomplete: int = 0
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def violation_count(self) -> int:
|
|
57
|
+
"""Total number of violations."""
|
|
58
|
+
return len(self.violations)
|
|
59
|
+
|
|
60
|
+
def violations_by_impact(self, impact: list[str] | None = None) -> list[A11yViolation]:
|
|
61
|
+
"""Filter violations by impact level."""
|
|
62
|
+
if not impact:
|
|
63
|
+
return self.violations
|
|
64
|
+
return [v for v in self.violations if v.impact in impact]
|
|
65
|
+
|
|
66
|
+
def assert_no_violations(
|
|
67
|
+
self, impact: list[str] | None = None, msg: str | None = None,
|
|
68
|
+
) -> "A11yReport":
|
|
69
|
+
"""Assert no violations at specified impact levels.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
impact: Impact levels to check (e.g., ["critical", "serious"]).
|
|
73
|
+
If None, checks all violations.
|
|
74
|
+
msg: Custom error message.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
self for chaining.
|
|
78
|
+
"""
|
|
79
|
+
filtered = self.violations_by_impact(impact)
|
|
80
|
+
if filtered:
|
|
81
|
+
details = "\n".join(
|
|
82
|
+
f" - [{v.impact.upper()}] {v.rule_id}: {v.description} "
|
|
83
|
+
f"({len(v.nodes)} elements)"
|
|
84
|
+
for v in filtered
|
|
85
|
+
)
|
|
86
|
+
error = msg or (
|
|
87
|
+
f"Found {len(filtered)} accessibility violation(s) "
|
|
88
|
+
f"at {self.url}:\n{details}"
|
|
89
|
+
)
|
|
90
|
+
raise AssertionError(error)
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def attach_to_allure(self) -> "A11yReport":
|
|
94
|
+
"""Attach report to Allure as JSON."""
|
|
95
|
+
try:
|
|
96
|
+
import allure
|
|
97
|
+
|
|
98
|
+
report_data = {
|
|
99
|
+
"url": self.url,
|
|
100
|
+
"violations": [
|
|
101
|
+
{
|
|
102
|
+
"rule_id": v.rule_id,
|
|
103
|
+
"impact": v.impact,
|
|
104
|
+
"description": v.description,
|
|
105
|
+
"help_url": v.help_url,
|
|
106
|
+
"elements": v.target_selectors,
|
|
107
|
+
}
|
|
108
|
+
for v in self.violations
|
|
109
|
+
],
|
|
110
|
+
"summary": {
|
|
111
|
+
"violations": self.violation_count,
|
|
112
|
+
"passes": self.passes,
|
|
113
|
+
"inapplicable": self.inapplicable,
|
|
114
|
+
"incomplete": self.incomplete,
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
allure.attach(
|
|
118
|
+
json.dumps(report_data, indent=2),
|
|
119
|
+
name="Accessibility Report",
|
|
120
|
+
attachment_type=allure.attachment_type.JSON,
|
|
121
|
+
)
|
|
122
|
+
except ImportError:
|
|
123
|
+
pass
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def summary(self) -> str:
|
|
127
|
+
"""Human-readable summary."""
|
|
128
|
+
return (
|
|
129
|
+
f"A11y Report for {self.url}: "
|
|
130
|
+
f"{self.violation_count} violations, {self.passes} passes, "
|
|
131
|
+
f"{self.inapplicable} inapplicable, {self.incomplete} incomplete"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class A11yScanner:
|
|
136
|
+
"""Accessibility scanner using axe-core via Playwright page.evaluate().
|
|
137
|
+
|
|
138
|
+
Injects axe-core from CDN into the page and runs WCAG compliance scans.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
axe_cdn_url: str = AXE_CDN_URL,
|
|
144
|
+
default_tags: list[str] | None = None,
|
|
145
|
+
disabled_rules: list[str] | None = None,
|
|
146
|
+
):
|
|
147
|
+
self.axe_cdn_url = axe_cdn_url
|
|
148
|
+
self.default_tags = default_tags or ["wcag2a", "wcag2aa"]
|
|
149
|
+
self.disabled_rules = disabled_rules or []
|
|
150
|
+
|
|
151
|
+
def _inject_axe(self, page: Any) -> None:
|
|
152
|
+
"""Inject axe-core library into the page."""
|
|
153
|
+
is_loaded = page.evaluate("typeof window.axe !== 'undefined'")
|
|
154
|
+
if not is_loaded:
|
|
155
|
+
page.evaluate(
|
|
156
|
+
"""async (url) => {
|
|
157
|
+
const script = document.createElement('script');
|
|
158
|
+
script.src = url;
|
|
159
|
+
document.head.appendChild(script);
|
|
160
|
+
await new Promise((resolve, reject) => {
|
|
161
|
+
script.onload = resolve;
|
|
162
|
+
script.onerror = reject;
|
|
163
|
+
});
|
|
164
|
+
}""",
|
|
165
|
+
self.axe_cdn_url,
|
|
166
|
+
)
|
|
167
|
+
logger.debug("axe-core injected into page")
|
|
168
|
+
|
|
169
|
+
def scan(
|
|
170
|
+
self,
|
|
171
|
+
driver: Any,
|
|
172
|
+
selector: str | None = None,
|
|
173
|
+
tags: list[str] | None = None,
|
|
174
|
+
disabled_rules: list[str] | None = None,
|
|
175
|
+
) -> A11yReport:
|
|
176
|
+
"""Run accessibility scan on the current page.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
driver: WebDriver instance with a `page` property.
|
|
180
|
+
selector: CSS selector to scope the scan (default: entire page).
|
|
181
|
+
tags: WCAG tags to test (e.g., ["wcag2a", "wcag2aa"]).
|
|
182
|
+
disabled_rules: Rule IDs to skip.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
A11yReport with scan results.
|
|
186
|
+
"""
|
|
187
|
+
page = driver.page
|
|
188
|
+
self._inject_axe(page)
|
|
189
|
+
|
|
190
|
+
effective_tags = tags or self.default_tags
|
|
191
|
+
effective_disabled = disabled_rules or self.disabled_rules
|
|
192
|
+
|
|
193
|
+
# Build axe.run options
|
|
194
|
+
options = {"runOnly": {"type": "tag", "values": effective_tags}}
|
|
195
|
+
if effective_disabled:
|
|
196
|
+
options["rules"] = {rule: {"enabled": False} for rule in effective_disabled}
|
|
197
|
+
|
|
198
|
+
context = f"'{selector}'" if selector else "document"
|
|
199
|
+
|
|
200
|
+
results = page.evaluate(
|
|
201
|
+
f"""async (options) => {{
|
|
202
|
+
const results = await window.axe.run({context}, options);
|
|
203
|
+
return results;
|
|
204
|
+
}}""",
|
|
205
|
+
options,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Parse violations
|
|
209
|
+
violations = []
|
|
210
|
+
for v in results.get("violations", []):
|
|
211
|
+
violations.append(A11yViolation(
|
|
212
|
+
rule_id=v["id"],
|
|
213
|
+
impact=v.get("impact", "unknown"),
|
|
214
|
+
description=v.get("description", ""),
|
|
215
|
+
help_url=v.get("helpUrl", ""),
|
|
216
|
+
nodes=v.get("nodes", []),
|
|
217
|
+
))
|
|
218
|
+
|
|
219
|
+
report = A11yReport(
|
|
220
|
+
url=page.url,
|
|
221
|
+
violations=violations,
|
|
222
|
+
passes=len(results.get("passes", [])),
|
|
223
|
+
inapplicable=len(results.get("inapplicable", [])),
|
|
224
|
+
incomplete=len(results.get("incomplete", [])),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
logger.info(report.summary())
|
|
228
|
+
return report
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""HTTP API driver using httpx.
|
|
2
|
+
|
|
3
|
+
Wraps httpx with JWT token management, response assertions, and retry logic::
|
|
4
|
+
|
|
5
|
+
from ankole.driver.api_driver import APIDriver
|
|
6
|
+
|
|
7
|
+
with APIDriver(base_url="http://localhost:8000") as api:
|
|
8
|
+
api.login("admin", "admin123")
|
|
9
|
+
resp = api.get("/api/members")
|
|
10
|
+
resp.assert_status(200)
|
|
11
|
+
members = resp.json()
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class APIResponse:
|
|
23
|
+
"""Wrapper around httpx.Response with assertion helpers."""
|
|
24
|
+
|
|
25
|
+
status_code: int
|
|
26
|
+
headers: dict
|
|
27
|
+
_json: Any = None
|
|
28
|
+
_text: str = ""
|
|
29
|
+
|
|
30
|
+
def json(self) -> Any:
|
|
31
|
+
"""Return parsed JSON body."""
|
|
32
|
+
return self._json
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def text(self) -> str:
|
|
36
|
+
"""Return response body as text."""
|
|
37
|
+
return self._text
|
|
38
|
+
|
|
39
|
+
def assert_status(self, expected: int) -> "APIResponse":
|
|
40
|
+
"""Assert response status code."""
|
|
41
|
+
assert self.status_code == expected, (
|
|
42
|
+
f"Expected status {expected}, got {self.status_code}: {self._text[:200]}"
|
|
43
|
+
)
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def assert_json_key(self, key: str, value: Any = None) -> "APIResponse":
|
|
47
|
+
"""Assert JSON response contains a key, optionally with specific value."""
|
|
48
|
+
data = self.json()
|
|
49
|
+
assert key in data, f"Key '{key}' not found in response: {data}"
|
|
50
|
+
if value is not None:
|
|
51
|
+
assert data[key] == value, (
|
|
52
|
+
f"Expected {key}={value!r}, got {data[key]!r}"
|
|
53
|
+
)
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def assert_json_key_absent(self, key: str) -> "APIResponse":
|
|
57
|
+
"""Assert JSON response does NOT contain a key."""
|
|
58
|
+
data = self.json()
|
|
59
|
+
assert key not in data, f"Key '{key}' should not be in response but found: {key}={data[key]!r}"
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def assert_json_list_length(self, min_length: int = 0) -> "APIResponse":
|
|
63
|
+
"""Assert JSON response is a list with minimum length."""
|
|
64
|
+
data = self.json()
|
|
65
|
+
assert isinstance(data, list), f"Expected list, got {type(data).__name__}"
|
|
66
|
+
assert len(data) >= min_length, (
|
|
67
|
+
f"Expected at least {min_length} items, got {len(data)}"
|
|
68
|
+
)
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def assert_schema(self, model_class: Any) -> "APIResponse":
|
|
72
|
+
"""Validate response JSON against a Pydantic v2 model.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
model_class: Pydantic BaseModel subclass.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
self for chaining.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
AssertionError: If validation fails.
|
|
82
|
+
"""
|
|
83
|
+
data = self.json()
|
|
84
|
+
try:
|
|
85
|
+
model_class.model_validate(data)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
raise AssertionError(
|
|
88
|
+
f"Schema validation failed for {model_class.__name__}: {e}"
|
|
89
|
+
) from e
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def assert_json_schema(self, schema: dict) -> "APIResponse":
|
|
93
|
+
"""Validate response JSON against a JSON Schema dict.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
schema: JSON Schema dictionary.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
self for chaining.
|
|
100
|
+
"""
|
|
101
|
+
import jsonschema
|
|
102
|
+
|
|
103
|
+
data = self.json()
|
|
104
|
+
try:
|
|
105
|
+
jsonschema.validate(instance=data, schema=schema)
|
|
106
|
+
except jsonschema.ValidationError as e:
|
|
107
|
+
raise AssertionError(
|
|
108
|
+
f"JSON Schema validation failed: {e.message}"
|
|
109
|
+
) from e
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def assert_matches_openapi(self, spec_path: str, operation_id: str) -> "APIResponse":
|
|
113
|
+
"""Validate response against an OpenAPI specification.
|
|
114
|
+
|
|
115
|
+
Loads the OpenAPI spec, finds the operation by ID, extracts the
|
|
116
|
+
response schema for the current status code, and validates.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
spec_path: Path to OpenAPI spec file (YAML or JSON).
|
|
120
|
+
operation_id: Operation ID to match.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
self for chaining.
|
|
124
|
+
"""
|
|
125
|
+
import json as json_mod
|
|
126
|
+
import yaml
|
|
127
|
+
import jsonschema
|
|
128
|
+
|
|
129
|
+
with open(spec_path, "r") as f:
|
|
130
|
+
if spec_path.endswith(".json"):
|
|
131
|
+
spec = json_mod.load(f)
|
|
132
|
+
else:
|
|
133
|
+
spec = yaml.safe_load(f)
|
|
134
|
+
|
|
135
|
+
# Find operation by operationId
|
|
136
|
+
response_schema = None
|
|
137
|
+
for path_obj in spec.get("paths", {}).values():
|
|
138
|
+
for method_obj in path_obj.values():
|
|
139
|
+
if isinstance(method_obj, dict) and method_obj.get("operationId") == operation_id:
|
|
140
|
+
status_str = str(self.status_code)
|
|
141
|
+
resp_def = method_obj.get("responses", {}).get(status_str, {})
|
|
142
|
+
content = resp_def.get("content", {})
|
|
143
|
+
json_content = content.get("application/json", {})
|
|
144
|
+
response_schema = json_content.get("schema")
|
|
145
|
+
break
|
|
146
|
+
if response_schema:
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
if response_schema is None:
|
|
150
|
+
raise AssertionError(
|
|
151
|
+
f"No schema found for operation '{operation_id}' "
|
|
152
|
+
f"with status {self.status_code} in {spec_path}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
jsonschema.validate(instance=self.json(), schema=response_schema)
|
|
157
|
+
except jsonschema.ValidationError as e:
|
|
158
|
+
raise AssertionError(
|
|
159
|
+
f"OpenAPI validation failed for '{operation_id}': {e.message}"
|
|
160
|
+
) from e
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class APIDriver:
|
|
165
|
+
"""HTTP client wrapper with JWT token management.
|
|
166
|
+
|
|
167
|
+
Supports context manager protocol for automatic cleanup.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
base_url: str = "http://localhost:8000",
|
|
173
|
+
timeout: float = 30.0,
|
|
174
|
+
token_endpoint: str = "/api/auth/login",
|
|
175
|
+
token_field: str = "access_token",
|
|
176
|
+
):
|
|
177
|
+
self.base_url = base_url.rstrip("/")
|
|
178
|
+
self.timeout = timeout
|
|
179
|
+
self.token_endpoint = token_endpoint
|
|
180
|
+
self.token_field = token_field
|
|
181
|
+
|
|
182
|
+
self._client = None
|
|
183
|
+
self._token: str | None = None
|
|
184
|
+
|
|
185
|
+
def start(self) -> "APIDriver":
|
|
186
|
+
"""Create httpx client."""
|
|
187
|
+
import httpx
|
|
188
|
+
|
|
189
|
+
self._client = httpx.Client(
|
|
190
|
+
base_url=self.base_url,
|
|
191
|
+
timeout=self.timeout,
|
|
192
|
+
)
|
|
193
|
+
logger.info(f"APIDriver started: {self.base_url}")
|
|
194
|
+
return self
|
|
195
|
+
|
|
196
|
+
def close(self) -> None:
|
|
197
|
+
"""Close httpx client."""
|
|
198
|
+
if self._client:
|
|
199
|
+
self._client.close()
|
|
200
|
+
self._token = None
|
|
201
|
+
logger.info("APIDriver closed")
|
|
202
|
+
|
|
203
|
+
def __enter__(self) -> "APIDriver":
|
|
204
|
+
return self.start()
|
|
205
|
+
|
|
206
|
+
def __exit__(self, *args) -> None:
|
|
207
|
+
self.close()
|
|
208
|
+
|
|
209
|
+
# -- Auth -----------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def login(self, username: str, password: str) -> APIResponse:
|
|
212
|
+
"""Authenticate and store JWT token."""
|
|
213
|
+
resp = self.post(
|
|
214
|
+
self.token_endpoint,
|
|
215
|
+
json={"username": username, "password": password},
|
|
216
|
+
)
|
|
217
|
+
if resp.status_code == 200:
|
|
218
|
+
data = resp.json()
|
|
219
|
+
self._token = data.get(self.token_field)
|
|
220
|
+
logger.info(f"Logged in as: {username}")
|
|
221
|
+
return resp
|
|
222
|
+
|
|
223
|
+
def logout(self) -> None:
|
|
224
|
+
"""Clear stored token."""
|
|
225
|
+
self._token = None
|
|
226
|
+
logger.info("Logged out (token cleared)")
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def is_authenticated(self) -> bool:
|
|
230
|
+
"""Check if a token is stored."""
|
|
231
|
+
return self._token is not None
|
|
232
|
+
|
|
233
|
+
# -- HTTP methods ---------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def _headers(self) -> dict:
|
|
236
|
+
"""Build request headers with auth token if available."""
|
|
237
|
+
headers = {"Content-Type": "application/json"}
|
|
238
|
+
if self._token:
|
|
239
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
240
|
+
return headers
|
|
241
|
+
|
|
242
|
+
def _wrap_response(self, resp) -> APIResponse:
|
|
243
|
+
"""Convert httpx.Response to APIResponse."""
|
|
244
|
+
try:
|
|
245
|
+
json_data = resp.json()
|
|
246
|
+
except Exception:
|
|
247
|
+
json_data = None
|
|
248
|
+
return APIResponse(
|
|
249
|
+
status_code=resp.status_code,
|
|
250
|
+
headers=dict(resp.headers),
|
|
251
|
+
_json=json_data,
|
|
252
|
+
_text=resp.text,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def get(self, path: str, **kwargs) -> APIResponse:
|
|
256
|
+
"""Send GET request."""
|
|
257
|
+
kwargs.setdefault("headers", self._headers())
|
|
258
|
+
resp = self._client.get(path, **kwargs)
|
|
259
|
+
logger.debug(f"GET {path} -> {resp.status_code}")
|
|
260
|
+
return self._wrap_response(resp)
|
|
261
|
+
|
|
262
|
+
def post(self, path: str, **kwargs) -> APIResponse:
|
|
263
|
+
"""Send POST request."""
|
|
264
|
+
kwargs.setdefault("headers", self._headers())
|
|
265
|
+
resp = self._client.post(path, **kwargs)
|
|
266
|
+
logger.debug(f"POST {path} -> {resp.status_code}")
|
|
267
|
+
return self._wrap_response(resp)
|
|
268
|
+
|
|
269
|
+
def put(self, path: str, **kwargs) -> APIResponse:
|
|
270
|
+
"""Send PUT request."""
|
|
271
|
+
kwargs.setdefault("headers", self._headers())
|
|
272
|
+
resp = self._client.put(path, **kwargs)
|
|
273
|
+
logger.debug(f"PUT {path} -> {resp.status_code}")
|
|
274
|
+
return self._wrap_response(resp)
|
|
275
|
+
|
|
276
|
+
def patch(self, path: str, **kwargs) -> APIResponse:
|
|
277
|
+
"""Send PATCH request."""
|
|
278
|
+
kwargs.setdefault("headers", self._headers())
|
|
279
|
+
resp = self._client.patch(path, **kwargs)
|
|
280
|
+
logger.debug(f"PATCH {path} -> {resp.status_code}")
|
|
281
|
+
return self._wrap_response(resp)
|
|
282
|
+
|
|
283
|
+
def delete(self, path: str, **kwargs) -> APIResponse:
|
|
284
|
+
"""Send DELETE request."""
|
|
285
|
+
kwargs.setdefault("headers", self._headers())
|
|
286
|
+
resp = self._client.delete(path, **kwargs)
|
|
287
|
+
logger.debug(f"DELETE {path} -> {resp.status_code}")
|
|
288
|
+
return self._wrap_response(resp)
|