pytest-api-core 1.0.1__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.
- pytest_api_core/__init__.py +20 -0
- pytest_api_core/assertions/__init__.py +3 -0
- pytest_api_core/assertions/response_assertions.py +350 -0
- pytest_api_core/auth/__init__.py +8 -0
- pytest_api_core/auth/auth_handlers.py +156 -0
- pytest_api_core/client/__init__.py +4 -0
- pytest_api_core/client/api_client.py +183 -0
- pytest_api_core/client/api_response.py +101 -0
- pytest_api_core/config/__init__.py +3 -0
- pytest_api_core/config/base_settings.py +54 -0
- pytest_api_core/config/config_manager.py +156 -0
- pytest_api_core/config/env_loader.py +110 -0
- pytest_api_core/fixtures/__init__.py +9 -0
- pytest_api_core/fixtures/api_fixtures.py +133 -0
- pytest_api_core/plugin.py +125 -0
- pytest_api_core/reporters/__init__.py +3 -0
- pytest_api_core/reporters/html_reporter.py +245 -0
- pytest_api_core/reporters/templates/report.html +469 -0
- pytest_api_core-1.0.1.dist-info/METADATA +314 -0
- pytest_api_core-1.0.1.dist-info/RECORD +24 -0
- pytest_api_core-1.0.1.dist-info/WHEEL +5 -0
- pytest_api_core-1.0.1.dist-info/entry_points.txt +2 -0
- pytest_api_core-1.0.1.dist-info/licenses/LICENSE +21 -0
- pytest_api_core-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-registered pytest fixtures provided by pytest-api-core.
|
|
3
|
+
|
|
4
|
+
These fixtures are available in every test suite that has the package
|
|
5
|
+
installed — no ``conftest.py`` import required.
|
|
6
|
+
|
|
7
|
+
Fixtures
|
|
8
|
+
--------
|
|
9
|
+
api_config (session) — resolved config dict for the target environment
|
|
10
|
+
api_client (session) — configured APIClient instance
|
|
11
|
+
api_bearer_auth (function) — BearerAuth instance (token from cfg or API_TOKEN)
|
|
12
|
+
api_basic_auth (function) — BasicAuth instance (from API_USERNAME / API_PASSWORD)
|
|
13
|
+
api_key_auth (function) — APIKeyAuth instance
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any, Generator
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from pytest_api_core.auth.auth_handlers import APIKeyAuth, BasicAuth, BearerAuth
|
|
23
|
+
from pytest_api_core.client.api_client import APIClient
|
|
24
|
+
from pytest_api_core.config.config_manager import ConfigManager
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Config fixture
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture(scope="session")
|
|
33
|
+
def api_config(request: pytest.FixtureRequest) -> dict[str, Any]:
|
|
34
|
+
"""
|
|
35
|
+
Returns the fully-resolved configuration dict for the active environment.
|
|
36
|
+
|
|
37
|
+
Override via CLI: ``--api-env=staging --api-config-dir=config/env``
|
|
38
|
+
Override via ini: ``api_env = staging``
|
|
39
|
+
Override via env-var: ``API_ENV=staging``
|
|
40
|
+
"""
|
|
41
|
+
env: str | None = (
|
|
42
|
+
request.config.getoption("--api-env", default=None)
|
|
43
|
+
or request.config.getini("api_env")
|
|
44
|
+
or None
|
|
45
|
+
)
|
|
46
|
+
base_url_override: str | None = request.config.getoption("--api-base-url", default=None)
|
|
47
|
+
settings_module: str | None = request.config.getini("api_settings_module") or None
|
|
48
|
+
|
|
49
|
+
manager = ConfigManager(
|
|
50
|
+
env=env,
|
|
51
|
+
cli_overrides={"base_url": base_url_override} if base_url_override else None,
|
|
52
|
+
settings_module=settings_module,
|
|
53
|
+
)
|
|
54
|
+
return manager.load()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Client fixture
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.fixture(scope="session")
|
|
63
|
+
def api_client(api_config: dict[str, Any]) -> Generator[APIClient, None, None]:
|
|
64
|
+
"""
|
|
65
|
+
Session-scoped APIClient built from *api_config*.
|
|
66
|
+
|
|
67
|
+
Automatically applies a BearerAuth if ``API_TOKEN`` env-var is set
|
|
68
|
+
or ``api_config["_token"]`` is present.
|
|
69
|
+
"""
|
|
70
|
+
auth = None
|
|
71
|
+
token = api_config.pop("_token", None) or os.environ.get("API_TOKEN")
|
|
72
|
+
if token:
|
|
73
|
+
auth = BearerAuth(token)
|
|
74
|
+
|
|
75
|
+
client = APIClient(
|
|
76
|
+
base_url=api_config["base_url"],
|
|
77
|
+
auth=auth,
|
|
78
|
+
timeout=api_config.get("timeout", 30),
|
|
79
|
+
verify_ssl=api_config.get("verify_ssl", True),
|
|
80
|
+
default_headers=api_config.get("headers"),
|
|
81
|
+
)
|
|
82
|
+
yield client
|
|
83
|
+
client.close()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Auth fixtures (function-scoped so tests can customise per-test)
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.fixture()
|
|
92
|
+
def api_bearer_auth(api_config: dict[str, Any]) -> BearerAuth:
|
|
93
|
+
"""
|
|
94
|
+
BearerAuth built from ``API_TOKEN`` env-var or ``api_config["token"]``.
|
|
95
|
+
Raises if no token is available.
|
|
96
|
+
"""
|
|
97
|
+
token = os.environ.get("API_TOKEN") or api_config.get("token")
|
|
98
|
+
if not token:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
"api_bearer_auth requires an API token. "
|
|
101
|
+
"Set the API_TOKEN environment variable or add 'token' to your env config."
|
|
102
|
+
)
|
|
103
|
+
return BearerAuth(token)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.fixture()
|
|
107
|
+
def api_basic_auth() -> BasicAuth:
|
|
108
|
+
"""
|
|
109
|
+
BasicAuth built from ``API_USERNAME`` / ``API_PASSWORD`` env-vars.
|
|
110
|
+
Raises if either is missing.
|
|
111
|
+
"""
|
|
112
|
+
username = os.environ.get("API_USERNAME", "")
|
|
113
|
+
password = os.environ.get("API_PASSWORD", "")
|
|
114
|
+
if not username or not password:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"api_basic_auth requires API_USERNAME and API_PASSWORD environment variables."
|
|
117
|
+
)
|
|
118
|
+
return BasicAuth(username, password)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.fixture()
|
|
122
|
+
def api_key_auth() -> APIKeyAuth:
|
|
123
|
+
"""
|
|
124
|
+
APIKeyAuth built from ``API_KEY_NAME`` / ``API_KEY_VALUE`` / ``API_KEY_LOCATION`` env-vars.
|
|
125
|
+
"""
|
|
126
|
+
name = os.environ.get("API_KEY_NAME", "x-api-key")
|
|
127
|
+
value = os.environ.get("API_KEY_VALUE", "")
|
|
128
|
+
location = os.environ.get("API_KEY_LOCATION", "header")
|
|
129
|
+
if not value:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
"api_key_auth requires API_KEY_VALUE environment variable."
|
|
132
|
+
)
|
|
133
|
+
return APIKeyAuth(name, value, location)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pytest plugin entry-point.
|
|
3
|
+
Registered via [project.entry-points."pytest11"] in pyproject.toml so pytest
|
|
4
|
+
auto-discovers and loads it without any conftest.py changes in consuming projects.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from pytest_api_core.config.env_loader import load_env_file
|
|
14
|
+
from pytest_api_core.fixtures.api_fixtures import (
|
|
15
|
+
api_config,
|
|
16
|
+
api_client,
|
|
17
|
+
api_bearer_auth,
|
|
18
|
+
api_basic_auth,
|
|
19
|
+
api_key_auth,
|
|
20
|
+
)
|
|
21
|
+
from pytest_api_core.reporters.html_reporter import HTMLReporter
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# CLI options
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
30
|
+
group = parser.getgroup("pytest-api-core", "API framework options")
|
|
31
|
+
group.addoption(
|
|
32
|
+
"--api-env",
|
|
33
|
+
action="store",
|
|
34
|
+
default=None,
|
|
35
|
+
help="Target environment name (maps to config/env/<name>.yaml)",
|
|
36
|
+
)
|
|
37
|
+
group.addoption(
|
|
38
|
+
"--api-config-dir",
|
|
39
|
+
action="store",
|
|
40
|
+
default="config/env",
|
|
41
|
+
help="Directory containing environment YAML files (default: config/env)",
|
|
42
|
+
)
|
|
43
|
+
group.addoption(
|
|
44
|
+
"--api-html-report",
|
|
45
|
+
action="store",
|
|
46
|
+
default=None,
|
|
47
|
+
metavar="PATH",
|
|
48
|
+
help="Path for the custom HTML report (e.g. reports/report.html)",
|
|
49
|
+
)
|
|
50
|
+
group.addoption(
|
|
51
|
+
"--api-base-url",
|
|
52
|
+
action="store",
|
|
53
|
+
default=None,
|
|
54
|
+
help="Override base_url from config",
|
|
55
|
+
)
|
|
56
|
+
group.addoption(
|
|
57
|
+
"--api-log-level",
|
|
58
|
+
action="store",
|
|
59
|
+
default=None,
|
|
60
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
61
|
+
help="Log level for pytest-api-core internals (default: WARNING)",
|
|
62
|
+
)
|
|
63
|
+
# Register ini options to suppress "Unknown config option" warnings
|
|
64
|
+
parser.addini("api_env", help="Default environment (e.g. dev, staging, prod)", default="dev")
|
|
65
|
+
parser.addini("api_html_report", help="Output path for the custom HTML report", default=None)
|
|
66
|
+
parser.addini("api_html_theme", help="Report theme: light or dark (default: dark)", default="dark")
|
|
67
|
+
parser.addini("api_html_title", help="Report browser tab title (default: API Test Report)", default="API Test Report")
|
|
68
|
+
parser.addini("api_html_header", help="Report page header text (default: API Test Report)", default="API Test Report")
|
|
69
|
+
parser.addini("api_log_level", help="Log level for pytest-api-core (DEBUG/INFO/WARNING/ERROR/CRITICAL)", default="WARNING")
|
|
70
|
+
parser.addini("api_settings_module", help="Dotted module path to settings (e.g. config.settings)", default=None)
|
|
71
|
+
parser.addini("api_dotenv_file", help="Path to .env file loaded at session start (default: .env)", default=".env")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# ini options (allow pytest.ini / pyproject.toml [tool.pytest.ini_options])
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
80
|
+
config.addinivalue_line("markers", "api: mark test as an API test")
|
|
81
|
+
|
|
82
|
+
# Load .env file before anything else so os.environ is fully populated
|
|
83
|
+
# for settings.py class attributes and fixtures
|
|
84
|
+
dotenv_path = config.getini("api_dotenv_file") or ".env"
|
|
85
|
+
load_env_file(dotenv_path)
|
|
86
|
+
|
|
87
|
+
# Configure the framework logger level from CLI option or ini
|
|
88
|
+
log_level = (
|
|
89
|
+
config.getoption("--api-log-level", default=None)
|
|
90
|
+
or config.getini("api_log_level")
|
|
91
|
+
or "WARNING"
|
|
92
|
+
)
|
|
93
|
+
logging.getLogger("pytest_api_core").setLevel(log_level.upper())
|
|
94
|
+
|
|
95
|
+
report_path = config.getoption("--api-html-report", default=None)
|
|
96
|
+
if not report_path:
|
|
97
|
+
report_path = config.getini("api_html_report")
|
|
98
|
+
if report_path:
|
|
99
|
+
env = (
|
|
100
|
+
config.getoption("--api-env", default=None)
|
|
101
|
+
or config.getini("api_env")
|
|
102
|
+
or "default"
|
|
103
|
+
)
|
|
104
|
+
report_path = _resolve_report_path(report_path, env)
|
|
105
|
+
plugin = HTMLReporter(report_path, config)
|
|
106
|
+
config.pluginmanager.register(plugin, "api-html-reporter")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _resolve_report_path(path: str, env: str) -> str:
|
|
110
|
+
"""Expand {timestamp} and {env} placeholders in the report path."""
|
|
111
|
+
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
112
|
+
return path.format(timestamp=timestamp, env=env)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Re-export fixtures so pytest can discover them from this module
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
__all__ = [
|
|
120
|
+
"api_config",
|
|
121
|
+
"api_client",
|
|
122
|
+
"api_bearer_auth",
|
|
123
|
+
"api_basic_auth",
|
|
124
|
+
"api_key_auth",
|
|
125
|
+
]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom HTML reporter for pytest-api-core.
|
|
3
|
+
|
|
4
|
+
Generates a single self-contained HTML file from a Jinja2 template with:
|
|
5
|
+
- Summary card: total / passed / failed / error / skipped counts + duration
|
|
6
|
+
- SVG donut chart
|
|
7
|
+
- Filterable, sortable results table
|
|
8
|
+
- Per-test expandable panels:
|
|
9
|
+
• HTTP call banners (method badge, URL, status, elapsed) with
|
|
10
|
+
tabbed Request Headers / Req Body / Res Headers / Res Body
|
|
11
|
+
• Assertion result rows (✅/❌ with expected vs actual)
|
|
12
|
+
• Captured logs, stdout, failure traceback
|
|
13
|
+
- Dark/light mode toggle
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import datetime
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import pytest
|
|
24
|
+
from jinja2 import Environment, PackageLoader
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_jinja_env = Environment(
|
|
28
|
+
loader=PackageLoader("pytest_api_core", "reporters/templates"),
|
|
29
|
+
autoescape=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Data model
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _TestRecord:
|
|
39
|
+
__slots__ = (
|
|
40
|
+
"node_id",
|
|
41
|
+
"name",
|
|
42
|
+
"outcome", # "passed" | "failed" | "error" | "skipped"
|
|
43
|
+
"duration", # seconds
|
|
44
|
+
"stdout",
|
|
45
|
+
"stderr",
|
|
46
|
+
"logs", # captured log output (displayed in template)
|
|
47
|
+
"longrepr", # failure text
|
|
48
|
+
"api_calls", # list[dict] — parsed from __API_CALL__ sentinels
|
|
49
|
+
"assertions", # list[dict] — parsed from __API_ASSERT__ sentinels
|
|
50
|
+
"markers",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def __init__(self, node_id: str) -> None:
|
|
54
|
+
self.node_id = node_id
|
|
55
|
+
self.name = node_id.split("::")[-1]
|
|
56
|
+
self.outcome = "unknown"
|
|
57
|
+
self.duration = 0.0
|
|
58
|
+
self.stdout = ""
|
|
59
|
+
self.stderr = ""
|
|
60
|
+
self.logs = ""
|
|
61
|
+
self.longrepr = ""
|
|
62
|
+
self.api_calls: list[dict[str, Any]] = []
|
|
63
|
+
self.assertions: list[dict[str, Any]] = []
|
|
64
|
+
self.markers: list[str] = []
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Plugin hooks
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class HTMLReporter:
|
|
73
|
+
"""pytest plugin that captures results and writes an HTML report on finish."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, report_path: str, config: pytest.Config) -> None:
|
|
76
|
+
self._path = Path(report_path)
|
|
77
|
+
self._records: dict[str, _TestRecord] = {}
|
|
78
|
+
self._start_time: datetime.datetime = datetime.datetime.now()
|
|
79
|
+
self._total_duration = 0.0
|
|
80
|
+
self._theme = config.getini("api_html_theme") or "dark"
|
|
81
|
+
self._title = config.getini("api_html_title") or "API Test Report"
|
|
82
|
+
self._header = config.getini("api_html_header") or "API Test Report"
|
|
83
|
+
|
|
84
|
+
# -- collection ----------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def pytest_collection_finish(self, session: pytest.Session) -> None:
|
|
87
|
+
# Pre-register every collected item so the table order is stable
|
|
88
|
+
for item in session.items:
|
|
89
|
+
rec = _TestRecord(item.nodeid)
|
|
90
|
+
rec.markers = [m.name for m in item.iter_markers()]
|
|
91
|
+
self._records[item.nodeid] = rec
|
|
92
|
+
|
|
93
|
+
# -- per-test result capture ---------------------------------------------
|
|
94
|
+
|
|
95
|
+
def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
|
|
96
|
+
node_id = report.nodeid
|
|
97
|
+
if node_id not in self._records:
|
|
98
|
+
self._records[node_id] = _TestRecord(node_id)
|
|
99
|
+
|
|
100
|
+
rec = self._records[node_id]
|
|
101
|
+
|
|
102
|
+
if report.when == "call" or (report.when == "setup" and report.failed):
|
|
103
|
+
rec.duration = report.duration
|
|
104
|
+
self._total_duration += report.duration
|
|
105
|
+
|
|
106
|
+
if report.passed:
|
|
107
|
+
rec.outcome = "passed"
|
|
108
|
+
elif report.failed:
|
|
109
|
+
rec.outcome = "failed" if report.when == "call" else "error"
|
|
110
|
+
elif report.skipped:
|
|
111
|
+
rec.outcome = "skipped"
|
|
112
|
+
|
|
113
|
+
# Capture printed output
|
|
114
|
+
if report.capstdout:
|
|
115
|
+
rec.stdout = report.capstdout
|
|
116
|
+
if report.capstderr:
|
|
117
|
+
rec.stderr = report.capstderr
|
|
118
|
+
|
|
119
|
+
# Parse captured log sections:
|
|
120
|
+
# - extract __API_CALL__ and __API_ASSERT__ sentinels into structured fields
|
|
121
|
+
# - keep human-readable log lines for the LOGS panel
|
|
122
|
+
log_text = ""
|
|
123
|
+
for header, content in report.sections:
|
|
124
|
+
if "log" not in header.lower() or not content.strip():
|
|
125
|
+
continue
|
|
126
|
+
clean_lines = []
|
|
127
|
+
for raw_line in _strip_ansi(content).splitlines():
|
|
128
|
+
if "__API_CALL__" in raw_line:
|
|
129
|
+
try:
|
|
130
|
+
payload = raw_line.split("__API_CALL__", 1)[1].strip()
|
|
131
|
+
rec.api_calls.append(json.loads(payload))
|
|
132
|
+
except (ValueError, IndexError):
|
|
133
|
+
pass
|
|
134
|
+
continue # don't add to human log
|
|
135
|
+
if "__API_ASSERT__" in raw_line:
|
|
136
|
+
try:
|
|
137
|
+
payload = raw_line.split("__API_ASSERT__", 1)[1].strip()
|
|
138
|
+
rec.assertions.append(json.loads(payload))
|
|
139
|
+
except (ValueError, IndexError):
|
|
140
|
+
pass
|
|
141
|
+
continue # don't add to human log
|
|
142
|
+
clean_lines.append(raw_line)
|
|
143
|
+
section_text = "\n".join(clean_lines).strip()
|
|
144
|
+
if section_text:
|
|
145
|
+
log_text += f"--- {header} ---\n{section_text}\n"
|
|
146
|
+
rec.logs = log_text
|
|
147
|
+
|
|
148
|
+
# Capture failure text
|
|
149
|
+
if report.longrepr:
|
|
150
|
+
rec.longrepr = str(report.longrepr)
|
|
151
|
+
|
|
152
|
+
elif report.when == "setup" and report.skipped:
|
|
153
|
+
rec.outcome = "skipped"
|
|
154
|
+
if report.longrepr:
|
|
155
|
+
rec.longrepr = str(report.longrepr)
|
|
156
|
+
|
|
157
|
+
# -- session finish — write report ----------------------------------------
|
|
158
|
+
|
|
159
|
+
def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int) -> None:
|
|
160
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
html_content = _render_report(
|
|
162
|
+
records=list(self._records.values()),
|
|
163
|
+
start_time=self._start_time,
|
|
164
|
+
total_duration=self._total_duration,
|
|
165
|
+
theme=self._theme,
|
|
166
|
+
title=self._title,
|
|
167
|
+
header=self._header,
|
|
168
|
+
)
|
|
169
|
+
self._path.write_text(html_content, encoding="utf-8")
|
|
170
|
+
# Print path relative to cwd for readability
|
|
171
|
+
try:
|
|
172
|
+
rel = self._path.relative_to(Path.cwd())
|
|
173
|
+
except ValueError:
|
|
174
|
+
rel = self._path
|
|
175
|
+
print(f"\n 📄 API HTML report: {rel}\n")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Rendering
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _render_report(
|
|
184
|
+
records: list[_TestRecord],
|
|
185
|
+
start_time: datetime.datetime,
|
|
186
|
+
total_duration: float,
|
|
187
|
+
theme: str,
|
|
188
|
+
title: str,
|
|
189
|
+
header: str,
|
|
190
|
+
) -> str:
|
|
191
|
+
counts: dict[str, int] = {"passed": 0, "failed": 0, "error": 0, "skipped": 0, "unknown": 0}
|
|
192
|
+
for rec in records:
|
|
193
|
+
counts[rec.outcome] = counts.get(rec.outcome, 0) + 1
|
|
194
|
+
|
|
195
|
+
total = len(records)
|
|
196
|
+
template = _jinja_env.get_template("report.html")
|
|
197
|
+
return template.render(
|
|
198
|
+
records=records,
|
|
199
|
+
generated_at=start_time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
200
|
+
duration=f"{total_duration:.2f}s",
|
|
201
|
+
total=total,
|
|
202
|
+
passed=counts["passed"],
|
|
203
|
+
failed=counts["failed"],
|
|
204
|
+
error=counts["error"],
|
|
205
|
+
skipped=counts["skipped"],
|
|
206
|
+
pass_rate=f"{(counts['passed'] / total * 100):.1f}" if total else "0.0",
|
|
207
|
+
donut_svg=_donut_svg(counts, total),
|
|
208
|
+
theme=theme,
|
|
209
|
+
report_title=title,
|
|
210
|
+
report_header=header,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _strip_ansi(text: str) -> str:
|
|
215
|
+
"""Strip ANSI escape codes from a string."""
|
|
216
|
+
return re.sub(r"\x1b\[[0-9;]*m", "", text)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _donut_svg(counts: dict[str, int], total: int) -> str:
|
|
220
|
+
if total == 0:
|
|
221
|
+
return '<svg width="140" height="140"><circle cx="70" cy="70" r="55" fill="none" stroke="#ccc" stroke-width="20"/></svg>'
|
|
222
|
+
|
|
223
|
+
colors = {"passed": "#22c55e", "failed": "#ef4444", "error": "#f97316", "skipped": "#94a3b8"}
|
|
224
|
+
r = 55
|
|
225
|
+
cx = cy = 70
|
|
226
|
+
circumference = 2 * 3.14159 * r
|
|
227
|
+
segments: list[str] = []
|
|
228
|
+
offset = 0.0
|
|
229
|
+
|
|
230
|
+
for outcome, color in colors.items():
|
|
231
|
+
count = counts.get(outcome, 0)
|
|
232
|
+
if count == 0:
|
|
233
|
+
continue
|
|
234
|
+
fraction = count / total
|
|
235
|
+
dash = fraction * circumference
|
|
236
|
+
segments.append(
|
|
237
|
+
f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="{color}" '
|
|
238
|
+
f'stroke-width="20" stroke-dasharray="{dash:.2f} {circumference:.2f}" '
|
|
239
|
+
f'stroke-dashoffset="-{offset:.2f}" transform="rotate(-90 {cx} {cy})"/>'
|
|
240
|
+
)
|
|
241
|
+
offset += dash
|
|
242
|
+
|
|
243
|
+
inner_text = f'<text x="{cx}" y="{cy+5}" text-anchor="middle" font-size="18" font-weight="bold" fill="currentColor">{total}</text>'
|
|
244
|
+
joined = "".join(segments)
|
|
245
|
+
return f'<svg width="140" height="140">{joined}{inner_text}</svg>'
|