robotframework-request-reporter 0.3.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.
@@ -0,0 +1,6 @@
1
+ """Compatibility import; use RequestReporter for new suites."""
2
+
3
+ from request_reporter import RequestReporter, __version__
4
+
5
+ APICaseReporter = RequestReporter
6
+ __all__ = ["APICaseReporter", "__version__"]
RequestReporter.py ADDED
@@ -0,0 +1,5 @@
1
+ """Public Robot Framework import for RequestReporter."""
2
+
3
+ from request_reporter import RequestReporter, __version__
4
+
5
+ __all__ = ["RequestReporter", "__version__"]
@@ -0,0 +1,278 @@
1
+ """Generate an independent HTML API evidence report for each Robot Framework test.
2
+
3
+ Import this library alongside RequestsLibrary. Capture each completed HTTP exchange
4
+ and associate assertions with its returned identifier using [Assert]. The library
5
+ registers its own listener and writes a report when the test finishes.
6
+
7
+ ```robotframework
8
+ *** Settings ***
9
+ Library RequestsLibrary
10
+ Library RequestReporter
11
+
12
+ *** Test Cases ***
13
+ Example
14
+ ${response}= GET http://localhost:8000/health expected_status=anything
15
+ ${id}= Capture Response Health ${response}
16
+ Assert ${id} HTTP status
17
+ ... Should Be Equal As Integers ${response.status_code} 200
18
+ ```
19
+
20
+ See [Importing] for configuration. This library does not execute requests or alter
21
+ Robot's failure continuation policy. HTTP transport errors without a response appear
22
+ in the test's final error message; they do not produce a fabricated exchange.
23
+ """
24
+
25
+ from datetime import UTC, datetime
26
+ from pathlib import Path
27
+ from time import perf_counter
28
+ from typing import Any
29
+
30
+ from requests import Response
31
+ from robot.api import logger
32
+ from robot.api.deco import keyword, library
33
+ from robot.libraries.BuiltIn import BuiltIn
34
+
35
+ from .models import Case, Exchange, ExecutionError, Validation
36
+ from .redaction import Redactor
37
+ from .render import write_report
38
+
39
+ __version__ = "0.3.0"
40
+ _HEADERS = "Authorization,Proxy-Authorization,Cookie,Set-Cookie,X-API-Key"
41
+ _FIELDS = "access_token,refresh_token,client_secret,password,token,api_key"
42
+
43
+
44
+ @library(scope="GLOBAL", version=__version__, doc_format="MARKDOWN", auto_keywords=False)
45
+ class RequestReporter:
46
+ """Robot library and listener for one self-contained HTML per test case."""
47
+
48
+ ROBOT_LISTENER_API_VERSION = 3
49
+
50
+ def __init__(
51
+ self,
52
+ output_dir: str | None = None,
53
+ language: str = "en",
54
+ redact_headers: str = _HEADERS,
55
+ redact_body_fields: str = _FIELDS,
56
+ ) -> None:
57
+ """Configure the reporter without creating files during import or Libdoc.
58
+
59
+ | Argument | Meaning |
60
+ | --- | --- |
61
+ | output_dir | Report directory; default is Robot's OUTPUT DIR/cases. |
62
+ | language | Interface language. Version 0.3 supports `en` only. |
63
+ | redact_headers | Comma-separated header names; matched case-insensitively. |
64
+ | redact_body_fields | Comma-separated JSON/form/query field names. |
65
+
66
+ ```robotframework
67
+ Library RequestReporter output_dir=${OUTPUT DIR}/cases
68
+ ```
69
+
70
+ Metadata and validation labels retain the language provided by the caller.
71
+ Redaction applies to this library's HTML only, not Robot/RequestsLibrary logs.
72
+ Binary responses are represented as a byte-count summary. Uploaded files and
73
+ multipart request bodies are summarized instead of embedded.
74
+ """
75
+ if language != "en":
76
+ raise ValueError("Only language=en is supported in version 0.3.")
77
+ self.ROBOT_LIBRARY_LISTENER = self
78
+ self.output_dir = output_dir
79
+ self.redact_headers = redact_headers
80
+ self.redact_body_fields = redact_body_fields
81
+ self.case: Case | None = None
82
+ self.redactor = Redactor(redact_headers, redact_body_fields)
83
+ self.started_at = 0.0
84
+ self._checking = 0
85
+ self._failure_keywords: list[Any] = []
86
+
87
+ def start_test(self, data: Any, result: Any) -> None:
88
+ self._checking = 0
89
+ self._failure_keywords = []
90
+ self.redactor = Redactor(self.redact_headers, self.redact_body_fields)
91
+ self.case = Case(
92
+ name=data.name,
93
+ source=str(data.source or ""),
94
+ test_id=result.id,
95
+ started=datetime.now(UTC).isoformat(),
96
+ )
97
+ self.started_at = perf_counter()
98
+
99
+ def end_keyword(self, data: Any, result: Any) -> None:
100
+ """Record leaf failures outside Check without parsing Robot's message."""
101
+ if self.case is None or self._checking or result.status != "FAIL":
102
+ return
103
+ if any(getattr(item, "status", "") == "FAIL" for item in result.body):
104
+ return
105
+ # Keep result references until parents reach their final status. A failed
106
+ # keyword caught by TRY/EXCEPT or an error-handling keyword is not an
107
+ # unhandled execution error, even when a later step fails the same case.
108
+ self._failure_keywords.append(result)
109
+
110
+ def end_test(self, data: Any, result: Any) -> None:
111
+ case = self._current()
112
+ case.status = result.status
113
+ if case.status == "FAIL":
114
+ for failed in self._failure_keywords:
115
+ parent = failed.parent
116
+ handled = False
117
+ while parent is not None and parent is not result:
118
+ if getattr(parent, "status", "") == "PASS":
119
+ handled = True
120
+ break
121
+ parent = parent.parent
122
+ if not handled:
123
+ case.execution_errors.append(
124
+ ExecutionError(
125
+ keyword=self.redactor.text(failed.name),
126
+ message=self.redactor.text(failed.message or "Keyword failed"),
127
+ )
128
+ )
129
+ case.message = self.redactor.text(result.message or "")
130
+ case.duration_ms = round((perf_counter() - self.started_at) * 1000, 2)
131
+ output = self.output_dir or str(
132
+ Path(BuiltIn().get_variable_value("${OUTPUT DIR}", ".")) / "cases"
133
+ )
134
+ try:
135
+ path = write_report(case, Path(output), self.redactor)
136
+ logger.info(f"API case report: {path}")
137
+ except (OSError, ValueError) as error:
138
+ result.status = "FAIL"
139
+ result.message = (
140
+ result.message + f"\nCould not write API case report: {error}"
141
+ ).strip()
142
+ logger.error(result.message)
143
+ finally:
144
+ self.case = None
145
+
146
+ def _current(self) -> Case:
147
+ if self.case is None:
148
+ raise RuntimeError("Reporter keywords must run inside an active Robot test case.")
149
+ return self.case
150
+
151
+ @keyword("Set Case Metadata")
152
+ def set_case_metadata(self, **metadata: Any) -> None:
153
+ """Add optional metadata to the current case, replacing matching keys.
154
+
155
+ Name, status, source and duration are captured automatically. Metadata is
156
+ optional; no case ID or data row is required to generate a report.
157
+
158
+ ```robotframework
159
+ Set Case Metadata case_id=DIST-002 environment=QA data_row=2
160
+ ```
161
+
162
+ Returns nothing. Raises an error outside an active test. Configured sensitive
163
+ field names are redacted. See [Capture Response].
164
+ """
165
+ self._current().metadata.update(self.redactor.clean(metadata))
166
+
167
+ @keyword("Capture Response")
168
+ def capture_response(self, name: str, response: Response) -> str:
169
+ """Capture one RequestsLibrary response and return its case-local request ID.
170
+
171
+ `response` must be a requests.Response with a prepared request. Captures the
172
+ method, URL, request/response headers and bodies, status code and elapsed time.
173
+ Capture before checking HTTP status so unsuccessful responses remain visible.
174
+
175
+ ```robotframework
176
+ ${response}= GET ${URL} expected_status=anything
177
+ ${id}= Capture Response Consult distributor ${response}
178
+ Assert ${id} Status
179
+ ... Should Be Equal As Integers ${response.status_code} 200
180
+ ```
181
+
182
+ Returns `request-1`, `request-2`, etc. IDs are valid only within the current
183
+ case. Raises an error for an invalid response. See [Assert] for assertions.
184
+ """
185
+ case = self._current()
186
+ if not isinstance(response, Response) or response.request is None:
187
+ raise TypeError("response must be a requests.Response with a prepared request.")
188
+ request = response.request
189
+ request_headers = self.redactor.headers(request.headers)
190
+ response_headers = self.redactor.headers(response.headers)
191
+ request_type = request.headers.get("Content-Type", "")
192
+ if "multipart/" in request_type:
193
+ request_body: Any = "[Multipart body omitted]"
194
+ else:
195
+ request_body = self.redactor.body(request.body, request_type)
196
+ response_type = response.headers.get("Content-Type", "").lower()
197
+ if any(x in response_type for x in ("json", "text/", "xml", "javascript")):
198
+ response_body = self.redactor.body(response.text, response_type)
199
+ else:
200
+ response_body = f"[Binary response: {len(response.content)} bytes]"
201
+ request_id = f"request-{len(case.exchanges) + 1}"
202
+ case.exchanges.append(
203
+ Exchange(
204
+ id=request_id,
205
+ name=name,
206
+ method=request.method or "",
207
+ url=self.redactor.url(request.url or ""),
208
+ status_code=response.status_code,
209
+ duration_ms=round(response.elapsed.total_seconds() * 1000, 2),
210
+ request_headers=request_headers,
211
+ response_headers=response_headers,
212
+ request_body=request_body,
213
+ response_body=response_body,
214
+ )
215
+ )
216
+ return request_id
217
+
218
+ @keyword("Assert")
219
+ def assert_that(self, request_id: str, label: str, assertion_keyword: str, *args: Any) -> Any:
220
+ """Run an assertion keyword, record its result, and propagate normal failures.
221
+
222
+ Associates the validation with `request_id`, not an implicit last request.
223
+ Supports Robot BuiltIn assertions and your own user keywords. Positional
224
+ arguments after `assertion_keyword` are forwarded unchanged.
225
+
226
+ ```robotframework
227
+ Assert ${id} Distributor type
228
+ ... Should Be Equal As Strings ${body}[tipoDistribuidor] AGENTE
229
+ ```
230
+
231
+ Returns the assertion's return value on success. Re-raises its failure on
232
+ error, preserving Robot's FAIL status. Use `robot:continue-on-failure` on a
233
+ business validation group when independent checks should all run.
234
+
235
+ Standard equality assertions show Expected/Actual. Non-empty checks show
236
+ `non-empty value`; other custom keywords show arguments and the error message.
237
+ `Campo Debe Tener Contenido` is also recognized as a non-empty assertion.
238
+ Unexecuted checks are not counted. Unknown request IDs fail before execution.
239
+ """
240
+ case = self._current()
241
+ exchange = next((x for x in case.exchanges if x.id == request_id), None)
242
+ if exchange is None:
243
+ raise ValueError(f"Unknown request ID in current case: {request_id}")
244
+ normalized = assertion_keyword.rsplit(".", 1)[-1].replace(" ", "").replace("_", "").lower()
245
+ actual = args[0] if args else None
246
+ expected = None
247
+ if normalized.startswith("shouldbeequal") and len(args) > 1:
248
+ expected = args[1]
249
+ elif normalized in {"shouldnotbeempty", "campodebetenercontenido"}:
250
+ expected = "non-empty value"
251
+ validation = Validation(
252
+ label=label,
253
+ keyword=assertion_keyword,
254
+ status="PASS",
255
+ actual=self.redactor.clean(actual),
256
+ expected=self.redactor.clean(expected),
257
+ arguments=self.redactor.clean(args),
258
+ )
259
+ exchange.validations.append(validation)
260
+ self._checking += 1
261
+ try:
262
+ return BuiltIn().run_keyword(assertion_keyword, *args)
263
+ except Exception as error:
264
+ validation.status = "FAIL"
265
+ validation.error = self.redactor.text(str(error))
266
+ raise
267
+ finally:
268
+ self._checking -= 1
269
+
270
+ @keyword("Capture HTTP Exchange", tags=["deprecated"])
271
+ def capture_http_exchange(self, name: str, response: Response) -> str:
272
+ """Compatibility alias for [Capture Response]."""
273
+ return self.capture_response(name, response)
274
+
275
+ @keyword("Check", tags=["deprecated"])
276
+ def check(self, request_id: str, label: str, assertion_keyword: str, *args: Any) -> Any:
277
+ """Compatibility alias for [Assert]."""
278
+ return self.assert_that(request_id, label, assertion_keyword, *args)
@@ -0,0 +1,50 @@
1
+ """Serializable report records, independent of Robot execution objects."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class Validation:
9
+ label: str
10
+ keyword: str
11
+ status: str
12
+ actual: Any = None
13
+ expected: Any = None
14
+ arguments: list[Any] = field(default_factory=list)
15
+ error: str = ""
16
+
17
+
18
+ @dataclass
19
+ class Exchange:
20
+ id: str
21
+ name: str
22
+ method: str
23
+ url: str
24
+ status_code: int
25
+ duration_ms: float
26
+ request_headers: dict[str, str]
27
+ response_headers: dict[str, str]
28
+ request_body: Any
29
+ response_body: Any
30
+ validations: list[Validation] = field(default_factory=list)
31
+
32
+
33
+ @dataclass
34
+ class ExecutionError:
35
+ keyword: str
36
+ message: str
37
+
38
+
39
+ @dataclass
40
+ class Case:
41
+ name: str
42
+ source: str
43
+ test_id: str
44
+ started: str
45
+ status: str = "RUNNING"
46
+ duration_ms: float = 0
47
+ message: str = ""
48
+ metadata: dict[str, Any] = field(default_factory=dict)
49
+ exchanges: list[Exchange] = field(default_factory=list)
50
+ execution_errors: list[ExecutionError] = field(default_factory=list)
@@ -0,0 +1,91 @@
1
+ """Redact configured fields before data reaches the template."""
2
+
3
+ import json
4
+ from typing import Any
5
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
6
+
7
+ MASK = "[REDACTED]"
8
+
9
+
10
+ class Redactor:
11
+ def __init__(self, headers: str, fields: str) -> None:
12
+ self.header_names = {x.strip().lower() for x in headers.split(",") if x.strip()}
13
+ self.field_names = {x.strip().lower() for x in fields.split(",") if x.strip()}
14
+ self.secrets: set[str] = set()
15
+
16
+ def remember(self, value: Any) -> None:
17
+ if isinstance(value, str) and value:
18
+ self.secrets.add(value)
19
+ if value.lower().startswith("bearer "):
20
+ self.secrets.add(value[7:])
21
+ elif isinstance(value, dict):
22
+ for item in value.values():
23
+ self.remember(item)
24
+ elif isinstance(value, list):
25
+ for item in value:
26
+ self.remember(item)
27
+
28
+ def text(self, value: str) -> str:
29
+ for secret in sorted(self.secrets, key=len, reverse=True):
30
+ value = value.replace(secret, MASK)
31
+ return value
32
+
33
+ def clean(self, value: Any) -> Any:
34
+ if isinstance(value, dict):
35
+ result = {}
36
+ for key, item in value.items():
37
+ if str(key).lower() in self.field_names:
38
+ self.remember(item)
39
+ result[str(key)] = MASK
40
+ else:
41
+ result[str(key)] = self.clean(item)
42
+ return result
43
+ if isinstance(value, (list, tuple)):
44
+ return [self.clean(item) for item in value]
45
+ if isinstance(value, str):
46
+ return self.text(value)
47
+ if value is None or isinstance(value, (bool, int, float)):
48
+ return value
49
+ return self.text(str(value))
50
+
51
+ def headers(self, values: Any) -> dict[str, str]:
52
+ result = {}
53
+ for key, value in values.items():
54
+ if key.lower() in self.header_names:
55
+ self.remember(value)
56
+ result[key] = MASK
57
+ else:
58
+ result[key] = self.text(str(value))
59
+ return result
60
+
61
+ def url(self, value: str) -> str:
62
+ parts = urlsplit(value)
63
+ pairs = []
64
+ for key, item in parse_qsl(parts.query, keep_blank_values=True):
65
+ if key.lower() in self.field_names:
66
+ self.remember(item)
67
+ item = MASK
68
+ pairs.append((key, item))
69
+ # URL userinfo can contain credentials even when no header exists.
70
+ netloc = parts.netloc
71
+ if "@" in netloc:
72
+ credentials, host = netloc.rsplit("@", 1)
73
+ self.remember(credentials)
74
+ netloc = MASK + "@" + host
75
+ return self.text(
76
+ urlunsplit((parts.scheme, netloc, parts.path, urlencode(pairs), parts.fragment))
77
+ )
78
+
79
+ def body(self, value: Any, content_type: str) -> Any:
80
+ if value is None:
81
+ return None
82
+ if isinstance(value, bytes):
83
+ value = value.decode("utf-8", errors="replace")
84
+ if not isinstance(value, str):
85
+ return self.clean(value)
86
+ if "application/x-www-form-urlencoded" in content_type:
87
+ return self.clean(dict(parse_qsl(value, keep_blank_values=True)))
88
+ try:
89
+ return self.clean(json.loads(value))
90
+ except (ValueError, TypeError):
91
+ return self.text(value)
@@ -0,0 +1,29 @@
1
+ """Render standalone HTML, with a stable JSON payload and no external assets."""
2
+
3
+ import re
4
+ from dataclasses import asdict
5
+ from importlib.resources import files
6
+ from pathlib import Path
7
+
8
+ from jinja2 import Environment, select_autoescape
9
+
10
+ from .models import Case
11
+ from .redaction import Redactor
12
+
13
+
14
+ def write_report(case: Case, directory: Path, redactor: Redactor) -> Path:
15
+ directory.mkdir(parents=True, exist_ok=True)
16
+ stem = re.sub(r"[^\w.-]+", "_", case.name, flags=re.UNICODE).strip("._")[:130] or "case"
17
+ target = directory / f"{stem}.html"
18
+ counter = 2
19
+ while target.exists():
20
+ target = directory / f"{stem}_{counter}.html"
21
+ counter += 1
22
+ environment = Environment(autoescape=select_autoescape(["html"]))
23
+ template = environment.from_string(
24
+ files("request_reporter").joinpath("templates/report.html").read_text(encoding="utf-8")
25
+ )
26
+ # Final cleaning also removes secrets learned in subsequent requests.
27
+ payload = redactor.clean(asdict(case))
28
+ target.write_text(template.render(case=payload), encoding="utf-8")
29
+ return target
@@ -0,0 +1,155 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <meta name="referrer" content="no-referrer">
6
+ <title>{{ case.name }} — Test report</title>
7
+ <style>
8
+ :root{color-scheme:light;--bg:#f5f7fb;--panel:#fff;--text:#1c293c;--muted:#52627a;--line:#dce3ef;--accent:#344bc0;--soft:#edf0ff;--pass:#146c38;--pass-bg:#e7f5eb;--fail:#b42332;--fail-bg:#ffeaec;--skip:#805b00;--skip-bg:#fff2c2}
9
+ :root[data-theme=dark]{color-scheme:dark;--bg:#121923;--panel:#1d2736;--text:#eff3fa;--muted:#a8b8cd;--line:#354359;--accent:#a6b6ff;--soft:#2a3653;--pass:#97e4af;--pass-bg:#193d2b;--fail:#ff9eaa;--fail-bg:#4a2530;--skip:#f3d16e;--skip-bg:#423819}
10
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 system-ui,sans-serif}main{max-width:1100px;margin:auto;padding:24px}h1{font-size:24px;margin:0 0 6px}h2{font-size:17px;margin:0 0 12px}h3{font-size:14px;margin:0}button{font:inherit;min-height:42px;border:1px solid var(--line);border-radius:8px;padding:8px 12px;background:var(--panel);color:var(--text);cursor:pointer}button:hover{background:var(--soft)}button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button[aria-selected=true],button[aria-pressed=true]{background:var(--soft);border-color:var(--accent);color:var(--accent)}.small{color:var(--muted);font-size:12px}.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.badge{background:var(--panel);padding:3px 8px;border-radius:5px;font-size:12px;font-weight:650}.pass{color:var(--pass)}.fail{color:var(--fail)}.skip{color:var(--skip)}.badge.pass{background:var(--pass-bg)}.badge.fail{background:var(--fail-bg)}.badge.skip{background:var(--skip-bg)}.stat.pass-stat{border-top:3px solid var(--pass)}.stat.fail-stat{border-top:3px solid var(--fail)}.case-message.skip{border-color:var(--skip);background:var(--skip-bg)}.requests small.pass{color:var(--pass)}.requests small.fail{color:var(--fail)}.requests button:has(.fail){border-left:3px solid var(--fail)}.requests button:has(.pass){border-left:3px solid var(--pass)}.theme-control{margin-left:auto}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:20px 0}.stat{padding:14px;background:var(--panel);border:1px solid var(--line);border-radius:10px}.stat strong{display:block;font-size:24px}.meta{display:flex;gap:8px 20px;flex-wrap:wrap;margin:12px 0;overflow-wrap:anywhere}.error{background:var(--panel);border-left:3px solid var(--fail);padding:12px;margin:14px 0;white-space:pre-wrap;overflow-wrap:anywhere}.layout{display:grid;grid-template-columns:220px minmax(0,1fr);gap:16px}.requests{display:flex;flex-direction:column;gap:8px}.requests button{text-align:left}.requests small{display:block;color:var(--muted)}.detail{min-width:0;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:20px}.url{background:var(--bg);padding:12px;border-radius:7px;overflow-wrap:anywhere}.url code{font-size:12px}.tabs{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0}.code{background:var(--bg);padding:14px;border-radius:7px;white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.65 ui-monospace,monospace;margin:0}.key{color:var(--accent)}.string{color:var(--pass)}table{border-collapse:collapse;table-layout:fixed;width:100%;font-size:13px}th,td{padding:10px 5px;border-bottom:1px solid var(--line);text-align:left;overflow-wrap:anywhere;vertical-align:top}th{color:var(--muted);font-weight:500}.validation{padding:12px 0;border-bottom:1px solid var(--line)}.validation p{margin:5px 0 0 20px;white-space:pre-wrap;overflow-wrap:anywhere}textarea{width:100%;min-height:100px;font:inherit;background:var(--bg);color:var(--text)}footer{margin-top:20px}.empty{padding:16px;color:var(--muted)}@media(max-width:700px){main{padding:14px}.layout{grid-template-columns:1fr}.requests{flex-direction:row;flex-wrap:wrap}.requests button{flex:1;min-width:145px}.stats{grid-template-columns:repeat(2,1fr)}.detail{padding:14px}h1{font-size:20px}}
11
+
12
+ [hidden]{display:none!important}.sections{display:flex;gap:8px;margin:20px 0}.sections button{font-weight:650}.summary-panel,.failure-panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:20px}.summary-panel p{margin:8px 0}.section-title{margin-top:20px}.method{font-weight:750;letter-spacing:.03em}.method-get{color:var(--pass);background:var(--pass-bg)}.method-post{color:var(--skip);background:var(--skip-bg)}.method-put,.method-head,.http-info{color:var(--accent);background:var(--soft)}.method-patch,.method-options{color:var(--skip);background:var(--skip-bg)}.method-delete{color:var(--fail);background:var(--fail-bg)}.http-success{color:var(--pass);background:var(--pass-bg)}.http-redirect{color:var(--skip);background:var(--skip-bg)}.http-error{color:var(--fail);background:var(--fail-bg)}.failure-table{overflow:auto}.failure-table table{min-width:540px}.failure-table button{border:0;padding:0;min-height:32px;background:none;color:var(--accent);text-align:left;text-decoration:underline}.technical{margin-top:18px}.technical summary{cursor:pointer;color:var(--muted)}.notice{padding:12px 16px;background:var(--fail-bg);border-left:3px solid var(--fail);border-radius:6px;margin-top:16px}.validation:focus{outline:2px solid var(--accent);outline-offset:5px}.summary-actions{margin-top:16px}.requests button .badge{display:inline-block}.stats{margin-top:0}
13
+ .request-line{display:flex;align-items:center;gap:12px}.request-line code{flex:1;min-width:0;font-size:13px}.request-result{display:flex;align-items:center;gap:12px;margin-top:12px;padding-top:12px;border-top:1px solid var(--line)}.copy-url{flex-shrink:0;display:grid;place-items:center}.url-status{margin-top:8px}.params-heading{display:flex;align-items:center;gap:10px;margin-bottom:12px}@media(max-width:700px){.request-line{flex-wrap:wrap}.request-line code{flex-basis:70%}}
14
+ </style>
15
+ <script>
16
+ (()=>{let theme=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';try{const stored=localStorage.getItem('api-case-reporter-theme');if(stored==='light'||stored==='dark')theme=stored;}catch(error){}document.documentElement.dataset.theme=theme;})();
17
+ </script>
18
+ </head>
19
+ <body>
20
+ <main>
21
+ <header><div class="row"><h1>{{ case.name }}</h1><span class="badge {{ case.status|lower }}">{{ case.status }}</span><button type="button" id="theme-toggle" class="theme-control" aria-label="Switch color theme">Light / Dark</button></div><div class="small"><time id="started" datetime="{{ case.started }}">{{ case.started }}</time></div></header>
22
+ <nav class="sections" aria-label="Report sections"><button data-section="summary" aria-pressed="true">Summary</button><button data-section="requests" aria-pressed="false">Requests</button><button data-section="failures" aria-pressed="false">Failures <span id="failure-count"></span></button></nav>
23
+ <section id="section-summary" aria-label="Summary">
24
+ <div class="stats">
25
+ <div class="stat"><span class="small">HTTP requests</span><strong id="request-count"></strong></div>
26
+ <div class="stat"><span class="small">Total assertions</span><strong id="assertion-count"></strong></div>
27
+ <div class="stat pass-stat"><span class="small">Passed assertions</span><strong class="pass" id="passed"></strong></div>
28
+ <div class="stat fail-stat"><span class="small">Failed assertions</span><strong class="fail" id="failed"></strong></div>
29
+ </div>
30
+ <div class="summary-panel"><h2>Test case information</h2><p><span class="small">Test case duration</span> · <strong>{{ '%.2f'|format(case.duration_ms / 1000) }} s</strong></p><p class="small">Each request includes its HTTP response time. HTTP status and assertion results are independent.</p>
31
+ {% if case.metadata %}<div class="meta" aria-label="Test case metadata">{% for key,value in case.metadata.items() %}<span><span class="small">{{ key }}:</span> {{ value }}</span>{% endfor %}</div>{% endif %}
32
+ {% if case.status == 'SKIP' and case.message %}<div class="error case-message skip">{{ case.message }}</div>{% endif %}
33
+ <div id="failure-notice"></div><div class="row summary-actions"><button data-section="requests">View requests</button></div></div>
34
+ </section>
35
+ <section id="section-requests" aria-label="Requests" hidden><div class="layout"><nav class="requests" id="requests" aria-label="Test case requests"></nav><section class="detail" aria-label="Selected request"><div id="url" class="url"></div><div class="tabs" role="tablist" aria-label="Request details">
36
+ {% for name in ['Response','Request','Params','Headers','Assertions'] %}<button type="button" role="tab" id="tab-{{ name|lower }}" data-tab="{{ name|lower }}" tabindex="{{ '0' if loop.first else '-1' }}" aria-selected="{{ 'true' if loop.first else 'false' }}" aria-controls="content">{{ name }}</button>{% endfor %}
37
+ </div><div id="content" role="tabpanel" aria-labelledby="tab-response" tabindex="0"></div></section></div></section>
38
+ <section id="section-failures" class="failure-panel" aria-label="Failures" hidden><h2>Failed assertions</h2><div id="failure-table" class="failure-table"></div><h2 class="section-title">Execution errors</h2><div id="execution-errors"></div>{% if case.message %}<details class="technical"><summary>Original Robot Framework message</summary><pre class="code">{{ case.message }}</pre></details>{% endif %}</section>
39
+ <footer class="small">One test case · {{ case.exchanges|length }} HTTP exchanges · Sensitive values are masked according to reporter configuration.</footer>
40
+ <noscript><p>This interactive report requires JavaScript.</p><pre class="code">{{ case.message }}</pre></noscript>
41
+ </main>
42
+ <script type="application/json" id="case-data">{{ case|tojson }}</script>
43
+ <script>
44
+ (()=>{
45
+ 'use strict';
46
+ const data=JSON.parse(document.getElementById('case-data').textContent);
47
+ const q=id=>document.getElementById(id);
48
+ const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
49
+ const format=v=>typeof v==='string'?v:JSON.stringify(v,null,2);
50
+ const body=v=>v===null?'<div class="code">No body</div>':'<pre class="code">'+esc(format(v)).replace(/(&quot;[^\n]*?&quot;)(\s*:)?/g,(m,a,b)=>'<span class="'+(b?'key':'string')+'">'+a+'</span>'+(b||''))+'</pre>';
51
+ const copyIcon='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V4a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h4"/></svg>';
52
+ let selected=0,tab='response',view='table';
53
+ function updateThemeButton(){
54
+ const dark=document.documentElement.dataset.theme==='dark';
55
+ q('theme-toggle').textContent=dark?'☀ Light mode':'☾ Dark mode';
56
+ q('theme-toggle').setAttribute('aria-label',dark?'Switch to light mode':'Switch to dark mode');
57
+ q('theme-toggle').setAttribute('aria-pressed',String(dark));
58
+ }
59
+ q('theme-toggle').addEventListener('click',()=>{
60
+ const theme=document.documentElement.dataset.theme==='dark'?'light':'dark';
61
+ document.documentElement.dataset.theme=theme;
62
+ try{localStorage.setItem('api-case-reporter-theme',theme);}catch(error){}
63
+ updateThemeButton();
64
+ });
65
+ updateThemeButton();
66
+ const started=new Date(data.started);
67
+ if(!Number.isNaN(started.getTime()))q('started').textContent=new Intl.DateTimeFormat('en',{dateStyle:'medium',timeStyle:'long',timeZone:'UTC'}).format(started);
68
+ const checks=data.exchanges.flatMap(r=>r.validations);
69
+ const failed=data.exchanges.flatMap((r,ri)=>r.validations.map((c,ci)=>({r,c,ri,ci})).filter(x=>x.c.status==='FAIL'));
70
+ const errors=data.execution_errors||[];
71
+ q('request-count').textContent=data.exchanges.length;
72
+ q('assertion-count').textContent=checks.length;
73
+ q('passed').textContent=checks.filter(c=>c.status==='PASS').length;
74
+ q('failed').textContent=failed.length;
75
+ q('failure-count').textContent='('+ (failed.length+errors.length) +')';
76
+ if(failed.length||errors.length||data.status==='FAIL'){
77
+ q('failure-notice').innerHTML='<div class="notice">'+failed.length+' failed assertions · '+errors.length+' execution errors <button data-section="failures">View failures</button></div>';
78
+ }
79
+ q('failure-table').innerHTML=failed.length?'<table><thead><tr><th>Request / Assertion</th><th>Expected</th><th>Actual</th></tr></thead><tbody>'+failed.map(({r,c,ri,ci})=>'<tr><td><span class="small">'+esc(r.name)+'</span><br><button data-failure-request="'+ri+'" data-assertion="'+ci+'">'+esc(c.label)+'</button></td><td>'+esc(c.expected===null?'See assertion details':format(c.expected))+'</td><td>'+esc(format(c.actual))+'</td></tr>').join('')+'</tbody></table>':'<p class="small">No failed assertions recorded.</p>';
80
+ q('execution-errors').innerHTML=errors.length?errors.map(e=>'<article class="error"><strong>'+esc(e.keyword)+'</strong><div>'+esc(e.message)+'</div></article>').join(''):'<p class="small">No unhandled keyword errors recorded. For failures outside keyword execution, see the original Robot message.</p>';
81
+ function showSection(name){
82
+ ['summary','requests','failures'].forEach(x=>q('section-'+x).hidden=x!==name);
83
+ document.querySelectorAll('.sections [data-section]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.section===name)));
84
+ }
85
+ function methodBadge(method){
86
+ const known=['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS'];
87
+ return '<span class="badge method '+(known.includes(method)?'method-'+method.toLowerCase():'')+'">'+esc(method)+'</span>';
88
+ }
89
+ function statusBadge(status){
90
+ const kind=status>=100&&status<200?'info':status>=200&&status<300?'success':status>=300&&status<400?'redirect':status>=400&&status<600?'error':'';
91
+ return '<span class="badge '+(kind?'http-'+kind:'')+'">HTTP '+esc(status)+'</span>';
92
+ }
93
+ function queryParams(url){try{return [...new URL(url).searchParams].map(([name,value])=>({name,value}));}catch(error){return [];}}
94
+ function render(){
95
+ q('requests').innerHTML=data.exchanges.map((r,i)=>{
96
+ const failed=r.validations.some(c=>c.status==='FAIL');
97
+ const state=r.validations.length?(failed?'Assertions FAIL':'Assertions PASS'):'No assertions';
98
+ return '<button type="button" data-request="'+i+'" aria-pressed="'+(i===selected)+'">'+(i+1)+'. '+esc(r.name)+'<small>'+methodBadge(r.method)+' '+statusBadge(r.status_code)+' · '+r.duration_ms+' ms</small><small class="'+(r.validations.length?(failed?'fail':'pass'):'')+'">'+state+'</small></button>';
99
+ }).join('');
100
+ document.querySelectorAll('[data-tab]').forEach(b=>{
101
+ b.setAttribute('aria-selected',String(b.dataset.tab===tab));
102
+ b.tabIndex=b.dataset.tab===tab?0:-1;
103
+ });
104
+ q('content').setAttribute('aria-labelledby','tab-'+tab);
105
+ if(!data.exchanges.length){
106
+ q('url').textContent='No completed HTTP exchanges';
107
+ q('content').innerHTML='<div class="empty">No response was captured for this case. See Summary and Failures for its final outcome.</div>';return;
108
+ }
109
+ const r=data.exchanges[selected];
110
+ q('url').innerHTML='<div class="request-line">'+methodBadge(r.method)+'<code>'+esc(r.url)+'</code><button class="copy-url" data-copy="url" aria-label="Copy request URL" title="Copy request URL">'+copyIcon+'</button></div><div class="request-result"><span class="small">Response</span>'+statusBadge(r.status_code)+'<span class="small">'+r.duration_ms+' ms</span></div><div id="url-copy-status" class="small url-status" role="status"></div>';
111
+ if(tab==='params'){const params=queryParams(r.url);q('content').innerHTML='<div class="params-heading"><h2>Query parameters</h2><button data-copy="params" aria-label="Copy query parameters as JSON">'+copyIcon+'</button></div><p class="small">From the final request URL. Repeated names and empty values are preserved.</p>'+(params.length?'<table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody>'+params.map(p=>'<tr><td>'+esc(p.name)+'</td><td>'+esc(p.value)+'</td></tr>').join('')+'</tbody></table>':'<div class="empty">No query parameters</div>')+'<div id="copy-status" role="status" class="small"></div>';}
112
+ if(tab==='response'||tab==='request')q('content').innerHTML='<h2>'+(tab==='response'?'Response':'Request')+' body</h2>'+body(r[tab+'_body']);
113
+ if(tab==='assertions')q('content').innerHTML=r.validations.length?r.validations.map((c,i)=>'<article class="validation" id="assertion-'+i+'" tabindex="-1"><strong class="'+(c.status==='PASS'?'pass':'fail')+'">'+(c.status==='PASS'?'✓':'×')+' '+c.status+' · '+esc(c.label)+'</strong><p class="small">Keyword: '+esc(c.keyword)+(c.expected!==null?'<br>Expected: '+esc(format(c.expected)):'')+'<br>Actual: '+esc(format(c.actual))+(c.expected===null?'<br>Arguments: '+esc(format(c.arguments)):'')+'</p>'+(c.error?'<div class="error">'+esc(c.error)+'</div>':'')+'</article>').join(''):'<div class="empty">No assertions recorded for this request.</div>';
114
+ if(tab==='headers')q('content').innerHTML='<div class="row"><button data-view="table" aria-pressed="'+(view==='table')+'">Table</button><button data-view="raw" aria-pressed="'+(view==='raw')+'">JSON</button></div>'+['request','response'].map(side=>{
115
+ const entries=Object.entries(r[side+'_headers']);
116
+ return '<div class="row" style="margin:16px 0 8px"><h3>'+(side==='request'?'Request':'Response')+' headers</h3><button data-copy="'+side+'" aria-label="Copy '+side+' headers as JSON" title="Copy headers as JSON">'+copyIcon+'</button></div>'+(view==='raw'?body(r[side+'_headers']):'<table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody>'+entries.map(([k,v])=>'<tr><td>'+esc(k)+'</td><td>'+esc(v)+'</td></tr>').join('')+'</tbody></table>');
117
+ }).join('')+'<div id="copy-status" role="status" class="small" style="margin-top:10px"></div>';
118
+ }
119
+ document.addEventListener('click',async e=>{
120
+ const b=e.target.closest('button');if(!b)return;
121
+ if(b.dataset.section){showSection(b.dataset.section);}
122
+ else if(b.dataset.failureRequest!==undefined){
123
+ selected=Number(b.dataset.failureRequest);tab='assertions';showSection('requests');render();
124
+ const assertion=q('assertion-'+b.dataset.assertion);assertion.scrollIntoView({block:'center'});assertion.focus({preventScroll:true});
125
+ }
126
+ else if(b.dataset.request!==undefined){selected=Number(b.dataset.request);render();}
127
+ else if(b.dataset.tab){tab=b.dataset.tab;render();}
128
+ else if(b.dataset.view){view=b.dataset.view;render();}
129
+ else if(b.dataset.copy){
130
+ const r=data.exchanges[selected];
131
+ const type=b.dataset.copy;
132
+ const text=type==='url'?r.url:JSON.stringify(type==='params'?queryParams(r.url):r[type+'_headers'],null,2);
133
+ const status=type==='url'?q('url-copy-status'):q('copy-status');
134
+ try{
135
+ if(!navigator.clipboard)throw Error('Clipboard unavailable');
136
+ await navigator.clipboard.writeText(text);
137
+ if(status)status.textContent=type==='url'?'Request URL copied':type==='params'?'Query parameters copied as JSON':'Headers copied as JSON';
138
+ }catch(error){
139
+ if(status){
140
+ status.innerHTML='<label>Copy manually<textarea readonly>'+esc(text)+'</textarea></label>';
141
+ const field=status.querySelector('textarea');field.focus();field.select();
142
+ }
143
+ }
144
+ }
145
+ });
146
+ document.addEventListener('keydown',e=>{
147
+ const b=e.target.closest('[data-tab]');if(!b||!['ArrowLeft','ArrowRight','Home','End'].includes(e.key))return;
148
+ e.preventDefault();const tabs=[...document.querySelectorAll('[data-tab]')];let i=tabs.indexOf(b);
149
+ i=e.key==='Home'?0:e.key==='End'?tabs.length-1:(i+(e.key==='ArrowRight'?1:-1)+tabs.length)%tabs.length;
150
+ tab=tabs[i].dataset.tab;render();tabs[i].focus();
151
+ });
152
+ render();
153
+ })();
154
+ </script>
155
+ </body></html>
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: robotframework-request-reporter
3
+ Version: 0.3.0
4
+ Summary: Standalone HTML API evidence reports for each Robot Framework test case.
5
+ Keywords: robotframework,api,report,requests
6
+ Author: Angel Molina
7
+ Requires-Python: >=3.12,<4
8
+ Classifier: Framework :: Robot Framework :: Library
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Requires-Dist: jinja2 (>=3.1.6,<4)
12
+ Requires-Dist: requests (>=2.32,<3)
13
+ Requires-Dist: robotframework (>=7.5,<8)
14
+ Project-URL: Documentation, https://angel-valdezzz.github.io/robotframework-request-reporter/
15
+ Project-URL: Repository, https://github.com/angel-valdezzz/robotframework-request-reporter
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Robot Framework Robot Framework Request Reporter
19
+
20
+ One standalone HTML evidence report per Robot Framework test case. Supports
21
+ RequestsLibrary responses, multiple HTTP requests, business assertions, metadata,
22
+ JSON formatting, request/response headers, Table/JSON and Copy.
23
+
24
+ Version 0.3.0 targets Python 3.12+ and Robot Framework 7.5+. No pytest adapter.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install robotframework-request-reporter==0.3.0
30
+ pip install robotframework-requests
31
+ ```
32
+
33
+ RequestsLibrary is installed separately. The WHL can also be downloaded from PyPI.
34
+
35
+ [Manual de usuario](https://angel-valdezzz.github.io/robotframework-request-reporter/) ·
36
+ [Referencia de keywords](https://angel-valdezzz.github.io/robotframework-request-reporter/keywords/) ·
37
+ [Ejemplo en vivo](https://angel-valdezzz.github.io/robotframework-request-reporter/examples/report.html) ·
38
+ [Paquete en PyPI](https://pypi.org/project/robotframework-request-reporter/) ·
39
+ [Ejemplo ejecutable](https://github.com/angel-valdezzz/robotframework-api-testing/tree/main)
40
+
41
+ ```robotframework
42
+ *** Settings ***
43
+ Library RequestsLibrary
44
+ Library RequestReporter
45
+
46
+ *** Test Cases ***
47
+ Example
48
+ ${response}= GET ${BASE_URL}/health expected_status=anything
49
+ ${id}= Capture Response Health ${response}
50
+ Assert ${id} HTTP status
51
+ ... Should Be Equal As Integers ${response.status_code} 200
52
+ ```
53
+
54
+ The library registers its listener automatically and writes into Robot's
55
+ `OUTPUT DIR/cases`. No separate listener option or generation keyword is needed.
56
+ Each report contains only one test case and works offline.
57
+
58
+ ## Development
59
+
60
+ ```bash
61
+ poetry install
62
+ poetry run ruff check .
63
+ poetry run ruff format --check .
64
+ poetry run mypy src
65
+ poetry run python scripts/verify.py
66
+ poetry build
67
+ poetry run python scripts/build_site.py
68
+ ```
69
+
70
+ Acceptance tests use Robot Framework and a loopback HTTP fixture. Some cases
71
+ intentionally fail; scripts/verify.py checks their exact results instead of
72
+ ignoring the Robot exit code. No live credentials or external endpoints are used.
73
+
74
+ ## Scope and limitations
75
+
76
+ - Metadata is optional. Name, status and duration come from Robot.
77
+ - Only English UI is currently supported; business labels may use any language.
78
+ - Configured JSON/form/query fields and headers are redacted in this reporter's HTML.
79
+ Robot and RequestsLibrary logs have their own independent logging behavior.
80
+ - Binary responses and multipart uploads are summarized, not embedded.
81
+ - HTTP transport errors without a Response appear in the final case error, with no
82
+ fabricated request. Keys missing before Assert executes appear as Robot errors.
83
+ - Pabot processes are supported with distinct output directories per worker. Sharing
84
+ one physical report directory across concurrent writers is not supported in 0.2.
85
+ - Light/Dark control follows the system initially and remembers your choice when storage is available.
86
+ - PASS, FAIL and SKIP use green, red and amber with distinct shades in both themes.
87
+
88
+ See the documentation for release workflow and Trusted Publisher configuration.
89
+
90
+ Headers can be viewed as formatted JSON and copied with the copy icon. The clipboard
91
+ and manual fallback both contain indented JSON with configured secrets masked.
92
+
93
+ The report opens on Summary with request/assertion counters, a readable UTC date
94
+ and case duration. Failures links each failed assertion to its request. Execution
95
+ errors outside Assert are recorded automatically; handled keyword failures are excluded.
96
+ HTTP status colors describe the response class independently of PASS/FAIL assertions.
97
+
98
+ Changes are integrated into main through pull requests with required CI checks.
99
+
100
+ ## Migration from APICaseReporter
101
+
102
+ Install `robotframework-request-reporter` and import `RequestReporter`. Do not install both distributions: the new wheel includes the legacy import. `Capture HTTP Exchange` and `Check` remain compatibility aliases; new suites use `Capture Response` and `Assert`. These keywords record an existing response and execute an assertion respectively; capturing does not send another request.
103
+
@@ -0,0 +1,10 @@
1
+ APICaseReporter/__init__.py,sha256=9j--TXN9v5zcnzXSdnm3GdC9thODltZpg4PijSGDlXA,203
2
+ RequestReporter.py,sha256=zuFlwQ16LW9RuNzCW9dpkcoeJy-873ADqt_xWLCfGfo,162
3
+ request_reporter/__init__.py,sha256=ZSE4pJD1Mhj9EULBmoZKtf6HMJN9sGqoNm6-1kM0zKQ,12264
4
+ request_reporter/models.py,sha256=9ZuYjfIwEfPUyEmngYNiPdZzBPl5mmPFy6j0_kbMCwQ,1079
5
+ request_reporter/redaction.py,sha256=_Vk51Ob7K7uLEQ9DI08goa5wCOCFhNI1qXFGAvsf4qY,3387
6
+ request_reporter/render.py,sha256=QZVX0eb5ucQYZNQl5UkYG5-IbCfK_itN3XHEWf-w0l0,1090
7
+ request_reporter/templates/report.html,sha256=Ni3qUInZfe8r5xQ2S1shu6RvlszCx-SJhdihoXUB7TI,19306
8
+ robotframework_request_reporter-0.3.0.dist-info/METADATA,sha256=KqJtNv8waS_s5oD4gRM9Kpc_CwsdkQF7O9fs835bn-M,4801
9
+ robotframework_request_reporter-0.3.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
10
+ robotframework_request_reporter-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.5.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any