robotframework-request-reporter 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- robotframework_request_reporter-0.3.0/PKG-INFO +103 -0
- robotframework_request_reporter-0.3.0/README.md +85 -0
- robotframework_request_reporter-0.3.0/pyproject.toml +57 -0
- robotframework_request_reporter-0.3.0/src/APICaseReporter/__init__.py +6 -0
- robotframework_request_reporter-0.3.0/src/RequestReporter.py +5 -0
- robotframework_request_reporter-0.3.0/src/request_reporter/__init__.py +278 -0
- robotframework_request_reporter-0.3.0/src/request_reporter/models.py +50 -0
- robotframework_request_reporter-0.3.0/src/request_reporter/redaction.py +91 -0
- robotframework_request_reporter-0.3.0/src/request_reporter/render.py +29 -0
- robotframework_request_reporter-0.3.0/src/request_reporter/templates/report.html +155 -0
|
@@ -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,85 @@
|
|
|
1
|
+
# Robot Framework Robot Framework Request Reporter
|
|
2
|
+
|
|
3
|
+
One standalone HTML evidence report per Robot Framework test case. Supports
|
|
4
|
+
RequestsLibrary responses, multiple HTTP requests, business assertions, metadata,
|
|
5
|
+
JSON formatting, request/response headers, Table/JSON and Copy.
|
|
6
|
+
|
|
7
|
+
Version 0.3.0 targets Python 3.12+ and Robot Framework 7.5+. No pytest adapter.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install robotframework-request-reporter==0.3.0
|
|
13
|
+
pip install robotframework-requests
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
RequestsLibrary is installed separately. The WHL can also be downloaded from PyPI.
|
|
17
|
+
|
|
18
|
+
[Manual de usuario](https://angel-valdezzz.github.io/robotframework-request-reporter/) ·
|
|
19
|
+
[Referencia de keywords](https://angel-valdezzz.github.io/robotframework-request-reporter/keywords/) ·
|
|
20
|
+
[Ejemplo en vivo](https://angel-valdezzz.github.io/robotframework-request-reporter/examples/report.html) ·
|
|
21
|
+
[Paquete en PyPI](https://pypi.org/project/robotframework-request-reporter/) ·
|
|
22
|
+
[Ejemplo ejecutable](https://github.com/angel-valdezzz/robotframework-api-testing/tree/main)
|
|
23
|
+
|
|
24
|
+
```robotframework
|
|
25
|
+
*** Settings ***
|
|
26
|
+
Library RequestsLibrary
|
|
27
|
+
Library RequestReporter
|
|
28
|
+
|
|
29
|
+
*** Test Cases ***
|
|
30
|
+
Example
|
|
31
|
+
${response}= GET ${BASE_URL}/health expected_status=anything
|
|
32
|
+
${id}= Capture Response Health ${response}
|
|
33
|
+
Assert ${id} HTTP status
|
|
34
|
+
... Should Be Equal As Integers ${response.status_code} 200
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The library registers its listener automatically and writes into Robot's
|
|
38
|
+
`OUTPUT DIR/cases`. No separate listener option or generation keyword is needed.
|
|
39
|
+
Each report contains only one test case and works offline.
|
|
40
|
+
|
|
41
|
+
## Development
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
poetry install
|
|
45
|
+
poetry run ruff check .
|
|
46
|
+
poetry run ruff format --check .
|
|
47
|
+
poetry run mypy src
|
|
48
|
+
poetry run python scripts/verify.py
|
|
49
|
+
poetry build
|
|
50
|
+
poetry run python scripts/build_site.py
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Acceptance tests use Robot Framework and a loopback HTTP fixture. Some cases
|
|
54
|
+
intentionally fail; scripts/verify.py checks their exact results instead of
|
|
55
|
+
ignoring the Robot exit code. No live credentials or external endpoints are used.
|
|
56
|
+
|
|
57
|
+
## Scope and limitations
|
|
58
|
+
|
|
59
|
+
- Metadata is optional. Name, status and duration come from Robot.
|
|
60
|
+
- Only English UI is currently supported; business labels may use any language.
|
|
61
|
+
- Configured JSON/form/query fields and headers are redacted in this reporter's HTML.
|
|
62
|
+
Robot and RequestsLibrary logs have their own independent logging behavior.
|
|
63
|
+
- Binary responses and multipart uploads are summarized, not embedded.
|
|
64
|
+
- HTTP transport errors without a Response appear in the final case error, with no
|
|
65
|
+
fabricated request. Keys missing before Assert executes appear as Robot errors.
|
|
66
|
+
- Pabot processes are supported with distinct output directories per worker. Sharing
|
|
67
|
+
one physical report directory across concurrent writers is not supported in 0.2.
|
|
68
|
+
- Light/Dark control follows the system initially and remembers your choice when storage is available.
|
|
69
|
+
- PASS, FAIL and SKIP use green, red and amber with distinct shades in both themes.
|
|
70
|
+
|
|
71
|
+
See the documentation for release workflow and Trusted Publisher configuration.
|
|
72
|
+
|
|
73
|
+
Headers can be viewed as formatted JSON and copied with the copy icon. The clipboard
|
|
74
|
+
and manual fallback both contain indented JSON with configured secrets masked.
|
|
75
|
+
|
|
76
|
+
The report opens on Summary with request/assertion counters, a readable UTC date
|
|
77
|
+
and case duration. Failures links each failed assertion to its request. Execution
|
|
78
|
+
errors outside Assert are recorded automatically; handled keyword failures are excluded.
|
|
79
|
+
HTTP status colors describe the response class independently of PASS/FAIL assertions.
|
|
80
|
+
|
|
81
|
+
Changes are integrated into main through pull requests with required CI checks.
|
|
82
|
+
|
|
83
|
+
## Migration from APICaseReporter
|
|
84
|
+
|
|
85
|
+
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.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "robotframework-request-reporter"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "Standalone HTML API evidence reports for each Robot Framework test case."
|
|
5
|
+
authors = [{name = "Angel Molina"}]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
requires-python = ">=3.12,<4"
|
|
8
|
+
dependencies = ["robotframework>=7.5,<8", "requests>=2.32,<3", "jinja2>=3.1.6,<4"]
|
|
9
|
+
keywords = ["robotframework", "api", "report", "requests"]
|
|
10
|
+
classifiers = ["Framework :: Robot Framework :: Library", "Programming Language :: Python :: 3", "Development Status :: 3 - Alpha"]
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
Repository = "https://github.com/angel-valdezzz/robotframework-request-reporter"
|
|
14
|
+
Documentation = "https://angel-valdezzz.github.io/robotframework-request-reporter/"
|
|
15
|
+
|
|
16
|
+
[tool.poetry]
|
|
17
|
+
packages = [{include = "request_reporter", from = "src"}, {include = "RequestReporter.py", from = "src"}, {include = "APICaseReporter", from = "src"}]
|
|
18
|
+
include = [{path = "src/request_reporter/templates/*.html", format = ["sdist", "wheel"]}]
|
|
19
|
+
|
|
20
|
+
[tool.poetry.group.dev.dependencies]
|
|
21
|
+
robotframework-requests = "0.9.7"
|
|
22
|
+
robotframework-datadriver = "1.11.2"
|
|
23
|
+
ruff = ">=0.12,<1"
|
|
24
|
+
mypy = ">=1.15,<2"
|
|
25
|
+
types-requests = "*"
|
|
26
|
+
twine = ">=6,<7"
|
|
27
|
+
robotframework-robocop = "^9.1.0"
|
|
28
|
+
|
|
29
|
+
[tool.poetry.group.docs.dependencies]
|
|
30
|
+
mkdocs-material = ">=9.6,<10"
|
|
31
|
+
markdown = ">=3.7,<4"
|
|
32
|
+
pygments = ">=2.19,<3"
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
target-version = "py312"
|
|
36
|
+
line-length = 100
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
40
|
+
|
|
41
|
+
[tool.mypy]
|
|
42
|
+
python_version = "3.12"
|
|
43
|
+
check_untyped_defs = true
|
|
44
|
+
disallow_untyped_defs = true
|
|
45
|
+
ignore_missing_imports = true
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["poetry-core>=2.0,<3"]
|
|
49
|
+
build-backend = "poetry.core.masonry.api"
|
|
50
|
+
|
|
51
|
+
[tool.robocop]
|
|
52
|
+
target-version = 7
|
|
53
|
+
|
|
54
|
+
[tool.robocop.lint]
|
|
55
|
+
threshold = "E"
|
|
56
|
+
|
|
57
|
+
[tool.robocop.format]
|
|
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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(/("[^\n]*?")(\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>
|