tracera-robot 0.1.0rc1__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.
- tracera_robot-0.1.0rc1/.gitignore +1 -0
- tracera_robot-0.1.0rc1/LICENSE.md +23 -0
- tracera_robot-0.1.0rc1/PKG-INFO +64 -0
- tracera_robot-0.1.0rc1/README.md +48 -0
- tracera_robot-0.1.0rc1/pyproject.toml +37 -0
- tracera_robot-0.1.0rc1/src/tracera_robot/__init__.py +7 -0
- tracera_robot-0.1.0rc1/src/tracera_robot/keywords.py +60 -0
- tracera_robot-0.1.0rc1/src/tracera_robot/listener.py +374 -0
- tracera_robot-0.1.0rc1/src/tracera_robot/runtime.py +143 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Package-local stub so hatchling sdist does not embed the monorepo root .gitignore.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Copyright (c) 2026 Tracera. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software and associated documentation files (the "Software") are the
|
|
4
|
+
proprietary and confidential property of Tracera.
|
|
5
|
+
|
|
6
|
+
No license to use, copy, modify, merge, publish, distribute, sublicense,
|
|
7
|
+
and/or sell copies of the Software is granted by default. Any use of the
|
|
8
|
+
Software requires a separate written agreement with Tracera.
|
|
9
|
+
|
|
10
|
+
Unauthorized use, reproduction, or distribution of the Software, in whole
|
|
11
|
+
or in part, is strictly prohibited and may result in civil and criminal
|
|
12
|
+
penalties.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
17
|
+
TRACERA BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
|
18
|
+
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
19
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
20
|
+
|
|
21
|
+
This is a draft notice pending a full commercial license / EULA.
|
|
22
|
+
Third-party open-source components included with or used by the Software
|
|
23
|
+
remain subject to their respective licenses.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tracera-robot
|
|
3
|
+
Version: 0.1.0rc1
|
|
4
|
+
Summary: Tracera Autotest adapter for Robot Framework
|
|
5
|
+
Project-URL: Homepage, https://tracera.dev/docs/robot
|
|
6
|
+
Project-URL: Bug-Tracker, https://github.com/tracera-dev/tracera-releases/issues
|
|
7
|
+
Author-email: Tracera <contact@tracera.dev>
|
|
8
|
+
License: SEE LICENSE IN LICENSE.md
|
|
9
|
+
License-File: LICENSE.md
|
|
10
|
+
Keywords: autotest,robotframework,test-reporting,testops,tracera
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Requires-Dist: tracera-core==0.1.0rc1
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: robotframework>=7; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# tracera-robot
|
|
18
|
+
|
|
19
|
+
Tracera Autotest adapter for Robot Framework (listener).
|
|
20
|
+
|
|
21
|
+
**Requires Python ≥ 3.11 and Robot Framework ≥ 7.**
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install tracera-robot
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
How-to: [Robot Framework](https://tracera.dev/docs/robot). Shared env / run mode: [Autotest adapters](https://tracera.dev/docs/adapters).
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
robot --listener tracera_robot.listener.TraceraListener suite.robot
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Optional companion library for comment / error / attachment / explicit step:
|
|
36
|
+
|
|
37
|
+
```robot
|
|
38
|
+
*** Settings ***
|
|
39
|
+
Library tracera_robot.keywords.TraceraKeywords
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Bind
|
|
43
|
+
|
|
44
|
+
Tags `tracera.id:…` / optional literal `tracera.name:…`. If `tracera.name` is omitted → Robot test name. Tags do not support `{param}` templates. Imperative `Tracera Test Case` via the companion library is also available.
|
|
45
|
+
|
|
46
|
+
## Skip
|
|
47
|
+
|
|
48
|
+
Stock Robot `Skip`. The reason becomes a result `comment`.
|
|
49
|
+
|
|
50
|
+
## Steps
|
|
51
|
+
|
|
52
|
+
User and library keywords → **Test** steps (nesting follows keyword calls). FOR / IF / WHILE / TRY are reported. **Test** Setup / Teardown → **Setup** / **Teardown**; Suite Setup / Teardown are not reported.
|
|
53
|
+
|
|
54
|
+
By default the step title is the keyword name. For a different title, use the companion library keyword `Tracera Step` (custom title, then the keyword to run). Python helpers can also use `tracera.step` or `@tracera.step("…")` from core.
|
|
55
|
+
|
|
56
|
+
## Comments, errors, and attachments
|
|
57
|
+
|
|
58
|
+
Use `TraceraKeywords` (`Tracera Comment`, `Tracera Error`, `Tracera Attachment`) — see [Autotest adapters](https://tracera.dev/docs/adapters).
|
|
59
|
+
|
|
60
|
+
## Parameterized
|
|
61
|
+
|
|
62
|
+
Stock `[Template]` — same `tracera.id` for every argument line under the template. Different id per row → one Robot test per row.
|
|
63
|
+
|
|
64
|
+
**Pabot:** workers share one run automatically — nothing extra to set.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# tracera-robot
|
|
2
|
+
|
|
3
|
+
Tracera Autotest adapter for Robot Framework (listener).
|
|
4
|
+
|
|
5
|
+
**Requires Python ≥ 3.11 and Robot Framework ≥ 7.**
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install tracera-robot
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
How-to: [Robot Framework](https://tracera.dev/docs/robot). Shared env / run mode: [Autotest adapters](https://tracera.dev/docs/adapters).
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
robot --listener tracera_robot.listener.TraceraListener suite.robot
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Optional companion library for comment / error / attachment / explicit step:
|
|
20
|
+
|
|
21
|
+
```robot
|
|
22
|
+
*** Settings ***
|
|
23
|
+
Library tracera_robot.keywords.TraceraKeywords
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Bind
|
|
27
|
+
|
|
28
|
+
Tags `tracera.id:…` / optional literal `tracera.name:…`. If `tracera.name` is omitted → Robot test name. Tags do not support `{param}` templates. Imperative `Tracera Test Case` via the companion library is also available.
|
|
29
|
+
|
|
30
|
+
## Skip
|
|
31
|
+
|
|
32
|
+
Stock Robot `Skip`. The reason becomes a result `comment`.
|
|
33
|
+
|
|
34
|
+
## Steps
|
|
35
|
+
|
|
36
|
+
User and library keywords → **Test** steps (nesting follows keyword calls). FOR / IF / WHILE / TRY are reported. **Test** Setup / Teardown → **Setup** / **Teardown**; Suite Setup / Teardown are not reported.
|
|
37
|
+
|
|
38
|
+
By default the step title is the keyword name. For a different title, use the companion library keyword `Tracera Step` (custom title, then the keyword to run). Python helpers can also use `tracera.step` or `@tracera.step("…")` from core.
|
|
39
|
+
|
|
40
|
+
## Comments, errors, and attachments
|
|
41
|
+
|
|
42
|
+
Use `TraceraKeywords` (`Tracera Comment`, `Tracera Error`, `Tracera Attachment`) — see [Autotest adapters](https://tracera.dev/docs/adapters).
|
|
43
|
+
|
|
44
|
+
## Parameterized
|
|
45
|
+
|
|
46
|
+
Stock `[Template]` — same `tracera.id` for every argument line under the template. Different id per row → one Robot test per row.
|
|
47
|
+
|
|
48
|
+
**Pabot:** workers share one run automatically — nothing extra to set.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tracera-robot"
|
|
3
|
+
version = "0.1.0rc1"
|
|
4
|
+
description = "Tracera Autotest adapter for Robot Framework"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "SEE LICENSE IN LICENSE.md" }
|
|
7
|
+
authors = [{ name = "Tracera", email = "contact@tracera.dev" }]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
keywords = ["tracera", "autotest", "robotframework", "test-reporting", "testops"]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"tracera-core==0.1.0rc1",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = ["robotframework>=7"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://tracera.dev/docs/robot"
|
|
19
|
+
Bug-Tracker = "https://github.com/tracera-dev/tracera-releases/issues"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/tracera_robot"]
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel.sources]
|
|
29
|
+
"src/tracera_robot" = "tracera_robot"
|
|
30
|
+
|
|
31
|
+
# Publish surface: runtime package + docs only (no tests / harness / gitignore).
|
|
32
|
+
[tool.hatch.build.targets.sdist]
|
|
33
|
+
only-include = [
|
|
34
|
+
"src/tracera_robot",
|
|
35
|
+
"LICENSE.md",
|
|
36
|
+
"README.md",
|
|
37
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Robot Framework library for imperative Tracera APIs.
|
|
2
|
+
|
|
3
|
+
Companion keywords are not auto-mapped as steps (PRODUCT) — they would duplicate
|
|
4
|
+
explicit ``tracera.step`` / side-effect APIs. Prefer tags for bind; user /
|
|
5
|
+
library/builtin keywords and FOR/IF/WHILE/TRY (listener API 3 control hooks)
|
|
6
|
+
build the step tree; use this library for comment / error / attachment and
|
|
7
|
+
optional ``tracera.test_case`` / ``tracera.step``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
from tracera_robot.runtime import tracera
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TraceraKeywords:
|
|
19
|
+
"""Companion keywords — comment / attach / step; not auto-mapped into the tree."""
|
|
20
|
+
|
|
21
|
+
ROBOT_LIBRARY_SCOPE = "GLOBAL"
|
|
22
|
+
|
|
23
|
+
def tracera_test_case(self, test_case_id: int | str, name: str | None = None) -> None:
|
|
24
|
+
tracera.test_case(int(test_case_id), name)
|
|
25
|
+
|
|
26
|
+
def tracera_comment(self, text: str) -> None:
|
|
27
|
+
tracera.comment(str(text))
|
|
28
|
+
|
|
29
|
+
def tracera_error(self, text: str) -> None:
|
|
30
|
+
tracera.error(str(text))
|
|
31
|
+
|
|
32
|
+
def tracera_attach_file(
|
|
33
|
+
self,
|
|
34
|
+
path: str,
|
|
35
|
+
mime_type: str = "application/octet-stream",
|
|
36
|
+
file_name: str | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
p = Path(path)
|
|
39
|
+
tracera.attachment(
|
|
40
|
+
file_name=file_name or p.name,
|
|
41
|
+
mime_type=mime_type,
|
|
42
|
+
path=str(p),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def tracera_attach_text(self, file_name: str, content: str) -> None:
|
|
46
|
+
tracera.attachment(
|
|
47
|
+
file_name=file_name,
|
|
48
|
+
mime_type="text/plain",
|
|
49
|
+
data=content.encode("utf-8"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def tracera_step(self, title: str, keyword: str, *args: object) -> object:
|
|
53
|
+
"""Run a Robot keyword inside an explicit ``tracera.step`` (custom title)."""
|
|
54
|
+
from robot.libraries.BuiltIn import BuiltIn
|
|
55
|
+
|
|
56
|
+
def body() -> object:
|
|
57
|
+
# RF stubs type run_keyword narrowly; keyword name + args are dynamic at runtime.
|
|
58
|
+
return BuiltIn().run_keyword(cast(Any, keyword), *cast(tuple[Any, ...], args))
|
|
59
|
+
|
|
60
|
+
return tracera.step(str(title), body)
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Robot Framework listener — bind via tags, auto keyword / control-structure steps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from contextlib import AbstractContextManager
|
|
8
|
+
from typing import Any, ClassVar, Literal
|
|
9
|
+
from urllib.parse import unquote
|
|
10
|
+
|
|
11
|
+
from tracera import (
|
|
12
|
+
run_with_context,
|
|
13
|
+
)
|
|
14
|
+
from tracera.buffer import PendingResult
|
|
15
|
+
from tracera.context import (
|
|
16
|
+
StepNode,
|
|
17
|
+
TestContext,
|
|
18
|
+
create_test_context,
|
|
19
|
+
empty_pm_doc,
|
|
20
|
+
get_context,
|
|
21
|
+
record_failure,
|
|
22
|
+
record_skip,
|
|
23
|
+
set_phase,
|
|
24
|
+
snapshot_payload,
|
|
25
|
+
)
|
|
26
|
+
from tracera.env_extras import build_env_vars_extra
|
|
27
|
+
from tracera.parallel import sync_pabot_env_from_robot
|
|
28
|
+
from tracera.pending import (
|
|
29
|
+
discard_pending_blobs,
|
|
30
|
+
pending_blobs_for_payload,
|
|
31
|
+
resolve_pending_attachments,
|
|
32
|
+
)
|
|
33
|
+
from tracera.session import (
|
|
34
|
+
ReporterState,
|
|
35
|
+
begin_reporting,
|
|
36
|
+
create_reporter_state,
|
|
37
|
+
duration_minutes_from_ms,
|
|
38
|
+
end_reporting,
|
|
39
|
+
)
|
|
40
|
+
from tracera_robot import runtime as rt
|
|
41
|
+
|
|
42
|
+
_ID_TAG = re.compile(r"^tracera\.id:(\d+)$", re.I)
|
|
43
|
+
_NAME_TAG = re.compile(r"^tracera\.name:(.+)$", re.I)
|
|
44
|
+
|
|
45
|
+
Status = Literal["Passed", "Failed", "Skipped"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TraceraListener:
|
|
49
|
+
"""Register with ``--listener tracera_robot.listener.TraceraListener``."""
|
|
50
|
+
|
|
51
|
+
ROBOT_LISTENER_API_VERSION: ClassVar[int] = 3
|
|
52
|
+
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
self._reporter: ReporterState = create_reporter_state()
|
|
55
|
+
self._ctx: TestContext | None = None
|
|
56
|
+
self._token: AbstractContextManager[TestContext] | None = None
|
|
57
|
+
self._started: float = 0.0
|
|
58
|
+
self._status: Status = "Passed"
|
|
59
|
+
self._open_steps: int = 0
|
|
60
|
+
|
|
61
|
+
def start_suite(self, data: Any, result: Any) -> None: # noqa: ARG002
|
|
62
|
+
if getattr(data, "parent", None) is None:
|
|
63
|
+
rt.clear_pending()
|
|
64
|
+
# Pabot injects pool/queue as Robot variables — promote before handoff detect.
|
|
65
|
+
sync_pabot_env_from_robot()
|
|
66
|
+
begin_reporting(self._reporter)
|
|
67
|
+
|
|
68
|
+
def end_suite(self, data: Any, result: Any) -> None: # noqa: ARG002
|
|
69
|
+
if getattr(data, "parent", None) is None:
|
|
70
|
+
end_reporting(self._reporter)
|
|
71
|
+
|
|
72
|
+
def start_test(self, data: Any, result: Any) -> None:
|
|
73
|
+
# RF listener API 3: `data.tags` keep unresolved `${VAR}`; resolved tags are on `result`.
|
|
74
|
+
raw_tags = getattr(result, "tags", None) or getattr(data, "tags", None) or []
|
|
75
|
+
case_id, name = _parse_tags(list(raw_tags))
|
|
76
|
+
self._ctx = create_test_context(
|
|
77
|
+
test_case_id=case_id,
|
|
78
|
+
autotest_name=(name or getattr(data, "name", "") or "")[:128],
|
|
79
|
+
)
|
|
80
|
+
rt.set_listener_context(self._ctx)
|
|
81
|
+
self._token = run_with_context(self._ctx)
|
|
82
|
+
self._token.__enter__()
|
|
83
|
+
self._started = time.time()
|
|
84
|
+
self._status = "Passed"
|
|
85
|
+
self._open_steps = 0
|
|
86
|
+
set_phase("test")
|
|
87
|
+
|
|
88
|
+
def end_test(self, data: Any, result: Any) -> None: # noqa: ARG002
|
|
89
|
+
if self._ctx is None:
|
|
90
|
+
return
|
|
91
|
+
status = str(getattr(result, "status", "") or "").upper()
|
|
92
|
+
if status == "SKIP":
|
|
93
|
+
self._status = "Skipped"
|
|
94
|
+
record_skip(self._ctx, getattr(result, "message", None) or None)
|
|
95
|
+
elif status != "PASS":
|
|
96
|
+
self._status = "Failed"
|
|
97
|
+
msg = getattr(result, "message", None)
|
|
98
|
+
if msg:
|
|
99
|
+
payload = snapshot_payload(self._ctx)
|
|
100
|
+
has_step_err = any(
|
|
101
|
+
isinstance(s, dict)
|
|
102
|
+
and isinstance(s.get("errorMessage"), str)
|
|
103
|
+
and s["errorMessage"].strip()
|
|
104
|
+
for s in payload["setup"] + payload["test"] + payload["teardown"]
|
|
105
|
+
)
|
|
106
|
+
if not has_step_err:
|
|
107
|
+
record_failure(self._ctx, msg)
|
|
108
|
+
self._enqueue_current()
|
|
109
|
+
if self._token is not None:
|
|
110
|
+
try:
|
|
111
|
+
self._token.__exit__(None, None, None)
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
rt.set_listener_context(None)
|
|
115
|
+
self._ctx = None
|
|
116
|
+
self._token = None
|
|
117
|
+
|
|
118
|
+
def start_keyword(self, data: Any, result: Any) -> None:
|
|
119
|
+
if self._ctx is None:
|
|
120
|
+
return
|
|
121
|
+
kw_type = str(getattr(data, "type", "") or "").upper()
|
|
122
|
+
if kw_type in ("SETUP", "SUITE SETUP", "TEST SETUP"):
|
|
123
|
+
set_phase("setup")
|
|
124
|
+
return
|
|
125
|
+
if kw_type in ("TEARDOWN", "SUITE TEARDOWN", "TEST TEARDOWN"):
|
|
126
|
+
set_phase("teardown")
|
|
127
|
+
return
|
|
128
|
+
if not _should_report_keyword(data, result):
|
|
129
|
+
return
|
|
130
|
+
self._begin_reported_step(getattr(data, "name", None) or "keyword")
|
|
131
|
+
|
|
132
|
+
def end_keyword(self, data: Any, result: Any) -> None:
|
|
133
|
+
if self._ctx is None:
|
|
134
|
+
return
|
|
135
|
+
kw_type = str(getattr(data, "type", "") or "").upper()
|
|
136
|
+
if kw_type in ("SETUP", "SUITE SETUP", "TEST SETUP"):
|
|
137
|
+
set_phase("test")
|
|
138
|
+
return
|
|
139
|
+
if kw_type in ("TEARDOWN", "SUITE TEARDOWN", "TEST TEARDOWN"):
|
|
140
|
+
set_phase("test")
|
|
141
|
+
return
|
|
142
|
+
if not _should_report_keyword(data, result):
|
|
143
|
+
return
|
|
144
|
+
self._end_reported_step(result)
|
|
145
|
+
|
|
146
|
+
# --- RF listener API 3: control structures are not start_keyword -------------
|
|
147
|
+
|
|
148
|
+
def start_for(self, data: Any, result: Any) -> None: # noqa: ARG002
|
|
149
|
+
self._begin_reported_step(_control_step_title(data))
|
|
150
|
+
|
|
151
|
+
def end_for(self, data: Any, result: Any) -> None: # noqa: ARG002
|
|
152
|
+
self._end_reported_step(result)
|
|
153
|
+
|
|
154
|
+
def start_while(self, data: Any, result: Any) -> None: # noqa: ARG002
|
|
155
|
+
self._begin_reported_step(_control_step_title(data))
|
|
156
|
+
|
|
157
|
+
def end_while(self, data: Any, result: Any) -> None: # noqa: ARG002
|
|
158
|
+
self._end_reported_step(result)
|
|
159
|
+
|
|
160
|
+
def start_if_branch(self, data: Any, result: Any) -> None: # noqa: ARG002
|
|
161
|
+
self._begin_reported_step(_control_step_title(data))
|
|
162
|
+
|
|
163
|
+
def end_if_branch(self, data: Any, result: Any) -> None: # noqa: ARG002
|
|
164
|
+
self._end_reported_step(result)
|
|
165
|
+
|
|
166
|
+
def start_try_branch(self, data: Any, result: Any) -> None: # noqa: ARG002
|
|
167
|
+
self._begin_reported_step(_control_step_title(data))
|
|
168
|
+
|
|
169
|
+
def end_try_branch(self, data: Any, result: Any) -> None: # noqa: ARG002
|
|
170
|
+
self._end_reported_step(result)
|
|
171
|
+
|
|
172
|
+
# start_for_iteration / start_while_iteration / start_if / start_try omitted on purpose
|
|
173
|
+
# (ITERATION + IF/TRY roots — body nests under FOR/WHILE / IF|TRY branches).
|
|
174
|
+
|
|
175
|
+
def _begin_reported_step(self, title: str) -> None:
|
|
176
|
+
if self._ctx is None:
|
|
177
|
+
return
|
|
178
|
+
_push_step(title)
|
|
179
|
+
self._open_steps += 1
|
|
180
|
+
|
|
181
|
+
def _end_reported_step(self, result: Any) -> None:
|
|
182
|
+
if self._ctx is None or self._open_steps <= 0:
|
|
183
|
+
return
|
|
184
|
+
failed = str(getattr(result, "status", "") or "").upper() == "FAIL"
|
|
185
|
+
msg = getattr(result, "message", None) if failed else None
|
|
186
|
+
_pop_step(failed=failed, message=msg)
|
|
187
|
+
self._open_steps -= 1
|
|
188
|
+
|
|
189
|
+
def _enqueue_current(self) -> None:
|
|
190
|
+
if self._ctx is None:
|
|
191
|
+
return
|
|
192
|
+
if (
|
|
193
|
+
self._reporter.soft_disabled
|
|
194
|
+
or not self._reporter.session
|
|
195
|
+
or not self._reporter.buffer
|
|
196
|
+
or not self._reporter.upload_pool
|
|
197
|
+
):
|
|
198
|
+
payload = snapshot_payload(self._ctx)
|
|
199
|
+
discard_pending_blobs(payload, take_blob=rt.take_pending)
|
|
200
|
+
rt.clear_pending()
|
|
201
|
+
return
|
|
202
|
+
payload = snapshot_payload(self._ctx)
|
|
203
|
+
env_vars_extra = build_env_vars_extra(
|
|
204
|
+
package_ids=["robotframework"],
|
|
205
|
+
version_key="ROBOT_VERSION",
|
|
206
|
+
)
|
|
207
|
+
duration_minutes = duration_minutes_from_ms((time.time() - self._started) * 1000)
|
|
208
|
+
session_ref = self._reporter.session
|
|
209
|
+
buffer = self._reporter.buffer
|
|
210
|
+
pending = pending_blobs_for_payload(rt.all_pending(), payload)
|
|
211
|
+
ctx = self._ctx
|
|
212
|
+
status = self._status
|
|
213
|
+
|
|
214
|
+
def job() -> None:
|
|
215
|
+
if not session_ref.reporting_enabled:
|
|
216
|
+
discard_pending_blobs(
|
|
217
|
+
payload,
|
|
218
|
+
take_blob=lambda aid: pending.pop(aid, None) or rt.take_pending(aid),
|
|
219
|
+
)
|
|
220
|
+
return
|
|
221
|
+
errs = resolve_pending_attachments(
|
|
222
|
+
session_ref.client,
|
|
223
|
+
payload,
|
|
224
|
+
resolve_blob=lambda aid: pending.get(aid) or rt.take_pending(aid),
|
|
225
|
+
)
|
|
226
|
+
if errs:
|
|
227
|
+
session_ref.set_last_error(errs[0])
|
|
228
|
+
if session_ref.config.require_report:
|
|
229
|
+
session_ref.note_failed_report()
|
|
230
|
+
session_ref.note_unsent_results()
|
|
231
|
+
return
|
|
232
|
+
buffer.enqueue(
|
|
233
|
+
PendingResult(
|
|
234
|
+
ctx=ctx,
|
|
235
|
+
status=status, # type: ignore[arg-type]
|
|
236
|
+
duration_minutes=duration_minutes,
|
|
237
|
+
payload=payload,
|
|
238
|
+
env_vars_extra=env_vars_extra,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
self._reporter.upload_pool.enqueue(job)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _push_step(title: str) -> None:
|
|
246
|
+
ctx = get_context()
|
|
247
|
+
if ctx is None:
|
|
248
|
+
return
|
|
249
|
+
node = StepNode(
|
|
250
|
+
id=ctx._next_step_id,
|
|
251
|
+
parent_id=ctx._step_stack[-1].id if ctx._step_stack else None,
|
|
252
|
+
result="NotSet",
|
|
253
|
+
action=empty_pm_doc(title),
|
|
254
|
+
expectation={"type": "doc", "content": [{"type": "paragraph"}]},
|
|
255
|
+
attachments=[],
|
|
256
|
+
error_message=None,
|
|
257
|
+
comment=None,
|
|
258
|
+
)
|
|
259
|
+
ctx._next_step_id += 1
|
|
260
|
+
parent = ctx._step_stack[-1] if ctx._step_stack else None
|
|
261
|
+
if parent:
|
|
262
|
+
parent.children.append(node)
|
|
263
|
+
else:
|
|
264
|
+
ctx._roots[ctx.section].append(node)
|
|
265
|
+
ctx._step_stack.append(node)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _pop_step(*, failed: bool, message: str | None) -> None:
|
|
269
|
+
from tracera.context import append_text, format_failure_text
|
|
270
|
+
|
|
271
|
+
ctx = get_context()
|
|
272
|
+
if ctx is None or not ctx._step_stack:
|
|
273
|
+
return
|
|
274
|
+
node = ctx._step_stack.pop()
|
|
275
|
+
if failed:
|
|
276
|
+
node.result = "Failed"
|
|
277
|
+
if message:
|
|
278
|
+
node.error_message = append_text(node.error_message, format_failure_text(message))
|
|
279
|
+
elif node.result == "NotSet":
|
|
280
|
+
node.result = "Passed"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# Control structures use dedicated API 3 hooks (not start_keyword). Still skip if
|
|
284
|
+
# a type ever leaks through the keyword path.
|
|
285
|
+
_SKIP_KEYWORD_TYPES = frozenset(
|
|
286
|
+
{
|
|
287
|
+
"FOR",
|
|
288
|
+
"WHILE",
|
|
289
|
+
"IF",
|
|
290
|
+
"ELSE IF",
|
|
291
|
+
"ELSE",
|
|
292
|
+
"TRY",
|
|
293
|
+
"EXCEPT",
|
|
294
|
+
"FINALLY",
|
|
295
|
+
"ITERATION",
|
|
296
|
+
"IF/ELSE ROOT",
|
|
297
|
+
"TRY/EXCEPT ROOT",
|
|
298
|
+
"GROUP",
|
|
299
|
+
"VAR",
|
|
300
|
+
"BREAK",
|
|
301
|
+
"CONTINUE",
|
|
302
|
+
"RETURN",
|
|
303
|
+
"ERROR",
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _control_step_title(data: Any) -> str:
|
|
309
|
+
"""Stable step title for FOR / WHILE / IF|TRY branches (RF log-like)."""
|
|
310
|
+
kw_type = str(getattr(data, "type", "") or "").upper()
|
|
311
|
+
if kw_type == "FOR":
|
|
312
|
+
assign = tuple(str(a) for a in (getattr(data, "assign", None) or ()))
|
|
313
|
+
flavor = str(getattr(data, "flavor", None) or "IN")
|
|
314
|
+
values = tuple(str(v) for v in (getattr(data, "values", None) or ()))
|
|
315
|
+
return " ".join(("FOR", *assign, flavor, *values))
|
|
316
|
+
if kw_type == "WHILE":
|
|
317
|
+
cond = getattr(data, "condition", None)
|
|
318
|
+
return f"WHILE {cond}" if cond not in (None, "") else "WHILE"
|
|
319
|
+
if kw_type in ("IF", "ELSE IF"):
|
|
320
|
+
cond = getattr(data, "condition", None)
|
|
321
|
+
return f"{kw_type} {cond}" if cond not in (None, "") else kw_type
|
|
322
|
+
if kw_type == "EXCEPT":
|
|
323
|
+
patterns = tuple(str(p) for p in (getattr(data, "patterns", None) or ()))
|
|
324
|
+
return "EXCEPT " + " ".join(patterns) if patterns else "EXCEPT"
|
|
325
|
+
if kw_type in ("ELSE", "TRY", "FINALLY"):
|
|
326
|
+
return kw_type
|
|
327
|
+
return str(getattr(data, "name", None) or kw_type or "keyword")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _is_tracera_companion_lib(lib: str) -> bool:
|
|
331
|
+
"""Companion `TraceraKeywords` — comment/attach/step APIs, not tree content."""
|
|
332
|
+
low = lib.lower().replace("\\", "/")
|
|
333
|
+
return "tracerakeywords" in low.replace("_", "") or "tracera_robot.keywords" in low
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _should_report_keyword(data: Any, result: Any | None = None) -> bool:
|
|
337
|
+
kw_type = str(getattr(data, "type", "") or "").upper()
|
|
338
|
+
if kw_type in _SKIP_KEYWORD_TYPES:
|
|
339
|
+
return False
|
|
340
|
+
# RF listener API 3: library ownership lives on `result`, not `data`.
|
|
341
|
+
lib = ""
|
|
342
|
+
if result is not None:
|
|
343
|
+
lib = getattr(result, "owner", None) or getattr(result, "libname", None) or ""
|
|
344
|
+
if not lib:
|
|
345
|
+
lib = getattr(data, "owner", None) or getattr(data, "libname", None) or ""
|
|
346
|
+
if isinstance(lib, str) and lib and _is_tracera_companion_lib(lib):
|
|
347
|
+
# Skip companion APIs (would double-nest under explicit `Tracera Step`).
|
|
348
|
+
return False
|
|
349
|
+
name = getattr(data, "name", "") or ""
|
|
350
|
+
if name.startswith("${") or name.startswith("@{"):
|
|
351
|
+
return False
|
|
352
|
+
# BuiltIn Skip / Fail — status/error land on the parent keyword or result; not tree leaves
|
|
353
|
+
# (Skipped UI = empty Steps; Failed UI = user keyword only with the fail text).
|
|
354
|
+
lib_l = str(lib).lower() if isinstance(lib, str) else ""
|
|
355
|
+
if str(name).strip().lower() in ("skip", "fail") and (not lib_l or lib_l == "builtin"):
|
|
356
|
+
return False
|
|
357
|
+
return True
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _parse_tags(tags: list[Any]) -> tuple[int | None, str | None]:
|
|
361
|
+
case_id: int | None = None
|
|
362
|
+
name: str | None = None
|
|
363
|
+
for raw in tags:
|
|
364
|
+
t = str(raw).strip()
|
|
365
|
+
m = _ID_TAG.match(t)
|
|
366
|
+
if m:
|
|
367
|
+
n = int(m.group(1))
|
|
368
|
+
if n > 0:
|
|
369
|
+
case_id = n
|
|
370
|
+
continue
|
|
371
|
+
m = _NAME_TAG.match(t)
|
|
372
|
+
if m:
|
|
373
|
+
name = unquote(m.group(1))[:128]
|
|
374
|
+
return case_id, name
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Companion API for Robot keywords (imperative bind + steps)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable, TypeVar, overload
|
|
8
|
+
|
|
9
|
+
from tracera import get_context, run_with_context
|
|
10
|
+
from tracera.context import AttachmentMeta, StepContext, TestContext
|
|
11
|
+
from tracera.runtime import create_tracera_runtime
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
_base = create_tracera_runtime()
|
|
15
|
+
_pending: dict[str, dict[str, Any]] = {}
|
|
16
|
+
# Listener holds the active test. Under Pabot, ContextVar can be empty when
|
|
17
|
+
# companion library keywords run — fall back to this pin.
|
|
18
|
+
_listener_ctx: TestContext | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def set_listener_context(ctx: TestContext | None) -> None:
|
|
22
|
+
global _listener_ctx
|
|
23
|
+
_listener_ctx = ctx
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _pinned_context() -> TestContext | None:
|
|
27
|
+
return get_context() or _listener_ctx
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def take_pending(ref: str) -> dict[str, Any] | None:
|
|
31
|
+
return _pending.pop(ref, None)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def all_pending() -> dict[str, dict[str, Any]]:
|
|
35
|
+
return dict(_pending)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def clear_pending() -> None:
|
|
39
|
+
_pending.clear()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_case(test_case_id: int, autotest_name: str | None = None) -> None:
|
|
43
|
+
"""Imperative bind to a Tracera test case (optional result name). Prefer ``tracera.id:`` tags."""
|
|
44
|
+
ctx = _pinned_context()
|
|
45
|
+
if ctx is None:
|
|
46
|
+
_base.test_case(test_case_id, autotest_name)
|
|
47
|
+
return
|
|
48
|
+
with run_with_context(ctx):
|
|
49
|
+
_base.test_case(test_case_id, autotest_name)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@overload
|
|
53
|
+
def step(title: str, body: Callable[[], T]) -> T: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@overload
|
|
57
|
+
def step(title: str, body: None = None) -> StepContext: ...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@overload
|
|
61
|
+
def step(title: Callable[..., T]) -> Callable[..., T]: ...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def step(
|
|
65
|
+
title: str | Callable[..., T], body: Callable[[], T] | None = None
|
|
66
|
+
) -> T | StepContext | Callable[..., T]:
|
|
67
|
+
"""Record a step — call / decorator / ``with`` / ``async with`` (same forms as ``tracera.step``)."""
|
|
68
|
+
ctx = _pinned_context()
|
|
69
|
+
if ctx is None:
|
|
70
|
+
return _base.step(title, body) # type: ignore[arg-type,return-value]
|
|
71
|
+
with run_with_context(ctx):
|
|
72
|
+
return _base.step(title, body) # type: ignore[arg-type,return-value]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def comment(text: str) -> None:
|
|
76
|
+
"""Comment on the open step, or on the result when no step is open."""
|
|
77
|
+
ctx = _pinned_context()
|
|
78
|
+
if ctx is None:
|
|
79
|
+
return
|
|
80
|
+
with run_with_context(ctx):
|
|
81
|
+
_base.comment(text)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def error(text: str) -> None:
|
|
85
|
+
"""Soft error text on the open step (or result). Does not by itself fail the test."""
|
|
86
|
+
ctx = _pinned_context()
|
|
87
|
+
if ctx is None:
|
|
88
|
+
return
|
|
89
|
+
with run_with_context(ctx):
|
|
90
|
+
_base.error(text)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def attachment(
|
|
94
|
+
meta: AttachmentMeta | dict[str, Any] | None = None,
|
|
95
|
+
*,
|
|
96
|
+
file_name: str | None = None,
|
|
97
|
+
mime_type: str | None = None,
|
|
98
|
+
data: bytes | str | None = None,
|
|
99
|
+
path: str | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Attach a file (pending upload) or an already-uploaded AttachmentMeta."""
|
|
102
|
+
ctx = _pinned_context()
|
|
103
|
+
|
|
104
|
+
def _do() -> None:
|
|
105
|
+
if isinstance(meta, AttachmentMeta) or (
|
|
106
|
+
isinstance(meta, dict) and "attachmentId" in meta and data is None and path is None
|
|
107
|
+
):
|
|
108
|
+
_base.attachment(meta) # type: ignore[arg-type]
|
|
109
|
+
return
|
|
110
|
+
fname = file_name or (Path(path).name if path else "attachment.bin")
|
|
111
|
+
mime = mime_type or "application/octet-stream"
|
|
112
|
+
blob_data = data.encode("utf-8") if isinstance(data, str) else data
|
|
113
|
+
pending_id = f"pending:{uuid.uuid4()}"
|
|
114
|
+
stored: dict[str, Any] = {
|
|
115
|
+
"fileName": fname,
|
|
116
|
+
"mimeType": mime,
|
|
117
|
+
}
|
|
118
|
+
if path:
|
|
119
|
+
stored["path"] = path
|
|
120
|
+
else:
|
|
121
|
+
stored["data"] = blob_data
|
|
122
|
+
_pending[pending_id] = stored
|
|
123
|
+
_base.attachment({"attachmentId": pending_id, "fileName": fname, "mimeType": mime})
|
|
124
|
+
|
|
125
|
+
if ctx is not None:
|
|
126
|
+
with run_with_context(ctx):
|
|
127
|
+
_do()
|
|
128
|
+
else:
|
|
129
|
+
_do()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _TraceraApi:
|
|
133
|
+
test_case = staticmethod(test_case)
|
|
134
|
+
step = staticmethod(step)
|
|
135
|
+
comment = staticmethod(comment)
|
|
136
|
+
error = staticmethod(error)
|
|
137
|
+
attachment = staticmethod(attachment)
|
|
138
|
+
reserve_attachment = staticmethod(_base.reserve_attachment)
|
|
139
|
+
get_context = staticmethod(get_context)
|
|
140
|
+
run_with_context = staticmethod(run_with_context)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
tracera = _TraceraApi()
|