robotframework-lynqa 0.1.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.
- robotframework_lynqa/__init__.py +5 -0
- robotframework_lynqa/atest/lynqa_search.robot +19 -0
- robotframework_lynqa/library.py +338 -0
- robotframework_lynqa/reporter.py +150 -0
- robotframework_lynqa/test/__init__.py +28 -0
- robotframework_lynqa/test/conftest.py +29 -0
- robotframework_lynqa/test/data_set/screenshot.png +0 -0
- robotframework_lynqa/test/data_set/test_lynqa_nominal.robot +17 -0
- robotframework_lynqa/test/data_set/test_lynqa_with_date.robot +17 -0
- robotframework_lynqa/test/data_set/test_lynqa_with_results.robot +15 -0
- robotframework_lynqa/test/data_set/test_lynqa_without_url.robot +16 -0
- robotframework_lynqa/test/data_set/testrun_full_status.py +129 -0
- robotframework_lynqa/test/data_set/testrun_full_status_failure.py +130 -0
- robotframework_lynqa/test/test_library.py +203 -0
- robotframework_lynqa/test/test_reporter.py +61 -0
- robotframework_lynqa-0.1.0.dist-info/METADATA +109 -0
- robotframework_lynqa-0.1.0.dist-info/RECORD +22 -0
- robotframework_lynqa-0.1.0.dist-info/WHEEL +5 -0
- robotframework_lynqa-0.1.0.dist-info/licenses/LICENSE +201 -0
- robotframework_lynqa-0.1.0.dist-info/scm_file_list.json +37 -0
- robotframework_lynqa-0.1.0.dist-info/scm_version.json +8 -0
- robotframework_lynqa-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
*** Settings ***
|
|
2
|
+
Documentation Acceptance test executed against the real Lynqa service.
|
|
3
|
+
... Requires the ``LYNQA_API_KEY`` environment variable to be set to a valid Lynqa API key.
|
|
4
|
+
... Runs a Google search for "lynqa" and checks that the first result mentions "Smartesting".
|
|
5
|
+
|
|
6
|
+
Library robotframework_lynqa.LynqaLibrary api_key=%{LYNQA_API_KEY}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
*** Variables ***
|
|
10
|
+
${LYNQA_URL} https://www.google.com/
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
*** Test Cases ***
|
|
14
|
+
Search Engine
|
|
15
|
+
Given go to the website
|
|
16
|
+
When I look at the search input
|
|
17
|
+
Then the search input exists
|
|
18
|
+
When I search for 'lynqa'
|
|
19
|
+
Then several results are displayed and the first results display 'Smartesting'
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Robot Framework test library and listener for the Lynqa execution agent.
|
|
2
|
+
|
|
3
|
+
This module exposes :class:`LynqaLibrary`, a Robot Framework library that lets you write scenarios in plain Gherkin and
|
|
4
|
+
have them executed by the Lynqa AI agent instead of by locally defined keywords.
|
|
5
|
+
|
|
6
|
+
Scenarios are written with the ``Given``/``When``/``Then`` keywords, each taking a single natural-language step as its
|
|
7
|
+
argument:
|
|
8
|
+
|
|
9
|
+
::
|
|
10
|
+
|
|
11
|
+
*** Settings ***
|
|
12
|
+
Library robotframework_lynqa.LynqaLibrary api_key=%{LYNQA_API_KEY}
|
|
13
|
+
|
|
14
|
+
*** Variables ***
|
|
15
|
+
${LYNQA_URL} https://example.com/
|
|
16
|
+
|
|
17
|
+
*** Test Cases ***
|
|
18
|
+
Search For An Item
|
|
19
|
+
Given the home page is open
|
|
20
|
+
When the user searches for an item
|
|
21
|
+
Then matching results are displayed
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import time
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import pylynqa
|
|
31
|
+
from pylynqa import LynqaClient, TestData, TestRunContext
|
|
32
|
+
from robot.api import SuiteVisitor, logger
|
|
33
|
+
from robot.api.deco import keyword
|
|
34
|
+
from robot.libraries.BuiltIn import BuiltIn
|
|
35
|
+
|
|
36
|
+
from robotframework_lynqa.reporter import StepReporter
|
|
37
|
+
|
|
38
|
+
BASE_URL = "https://api.lynqa.smartesting.com"
|
|
39
|
+
|
|
40
|
+
GHERKIN_KEYWORDS = ("Given", "When", "Then")
|
|
41
|
+
|
|
42
|
+
# Statuses that mean the run is not finished yet.
|
|
43
|
+
PENDING_STATUSES = ("waiting", "running", "not_run")
|
|
44
|
+
|
|
45
|
+
# How often to poll the run status, and how long to wait before giving up.
|
|
46
|
+
POLL_INTERVAL_SECONDS = 10
|
|
47
|
+
RUN_TIMEOUT_SECONDS = 3600
|
|
48
|
+
|
|
49
|
+
# Variable names
|
|
50
|
+
URL_VAR = "${LYNQA_URL}"
|
|
51
|
+
LANGUAGE_VAR = "${LYNQA_LANGUAGE}"
|
|
52
|
+
DATETIME_VAR = "${LYNQA_DATETIME}"
|
|
53
|
+
SECRETS_VAR = "&{LYNQA_SECRETS}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _GherkinStepCollector(SuiteVisitor):
|
|
57
|
+
"""Collect the Gherkin steps of a running test case.
|
|
58
|
+
|
|
59
|
+
Walks a Robot Framework test case and records every keyword call whose name is one of :data:`GHERKIN_KEYWORDS`,
|
|
60
|
+
rebuilding the original Gherkin step (keyword name followed by its argument text).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
"""Initialize the collector with an empty list of steps."""
|
|
65
|
+
self.steps: list[str] = []
|
|
66
|
+
|
|
67
|
+
def start_keyword(self, keyword) -> None:
|
|
68
|
+
"""Record the keyword when it is one of the Gherkin step keywords.
|
|
69
|
+
|
|
70
|
+
:param keyword: Running keyword visited by Robot Framework.
|
|
71
|
+
"""
|
|
72
|
+
if keyword.name in GHERKIN_KEYWORDS:
|
|
73
|
+
text_step = ""
|
|
74
|
+
if len(keyword.args) > 0:
|
|
75
|
+
text_step = keyword.args[0]
|
|
76
|
+
self.steps.append(f"{keyword.name} {text_step}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LynqaLibrary:
|
|
80
|
+
"""Robot Framework library that runs Gherkin scenarios via Lynqa.
|
|
81
|
+
|
|
82
|
+
Acts both as a test library exposing the Gherkin step keywords (``Given``/``When``/``Then``) and as a listener that
|
|
83
|
+
captures each scenario and submits it to the Lynqa execution agent.
|
|
84
|
+
|
|
85
|
+
**Execution timeout**:
|
|
86
|
+
|
|
87
|
+
Once a scenario is submitted, the library polls Lynqa until the run reaches a final status, waiting up to **1
|
|
88
|
+
hour** before failing the test with a timeout error. To change this behaviour, use Robot Framework's timeout
|
|
89
|
+
feature (the ``Test Timeout`` suite setting or a ``[Timeout]`` on the test case), which caps how long a test is
|
|
90
|
+
allowed to run.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
ROBOT_LIBRARY_SCOPE = "TEST"
|
|
94
|
+
ROBOT_LIBRARY_DOC_FORMAT = "reST"
|
|
95
|
+
ROBOT_AUTO_KEYWORDS = False
|
|
96
|
+
|
|
97
|
+
def __init__(self, api_key: str, base_url: str = BASE_URL) -> None:
|
|
98
|
+
"""Initialize the Lynqa library.
|
|
99
|
+
|
|
100
|
+
:param api_key: API key used to authenticate against the Lynqa service.
|
|
101
|
+
:param base_url: Base URL of the Lynqa API.
|
|
102
|
+
"""
|
|
103
|
+
self.ROBOT_LIBRARY_LISTENER = self
|
|
104
|
+
self._init_client(api_key, base_url)
|
|
105
|
+
self._run_id: str = ""
|
|
106
|
+
self._start_error: Exception | None = None
|
|
107
|
+
self._step_index: int = 0
|
|
108
|
+
self._reporter: StepReporter | None = None
|
|
109
|
+
|
|
110
|
+
self.scenario: str = ""
|
|
111
|
+
self.url: str | None = ""
|
|
112
|
+
self.context: TestRunContext = TestRunContext()
|
|
113
|
+
self.results: dict[str, Any] = {}
|
|
114
|
+
|
|
115
|
+
def _init_client(self, api_key: str, base_url: str):
|
|
116
|
+
"""Initialize the Lynqa client.
|
|
117
|
+
|
|
118
|
+
:param api_key: API key used to authenticate against the Lynqa service.
|
|
119
|
+
:param base_url: Base URL of the Lynqa API.
|
|
120
|
+
"""
|
|
121
|
+
pylynqa.client._PACKAGE_CONSUMER_NAME = "petit-robot:robotframework-lynqa" # ty: ignore[invalid-assignment] # ruff: ignore[private-member-access]
|
|
122
|
+
self._client = LynqaClient(api_key=api_key, base_url=base_url)
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# Listener interface
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def start_test(self, data, result) -> None:
|
|
129
|
+
"""Collect the scenario's Gherkin steps when a test starts.
|
|
130
|
+
|
|
131
|
+
Reads the running test case with a :class:`_GherkinStepCollector` visitor and stores its
|
|
132
|
+
``Given``/``When``/``Then`` steps as the current scenario.
|
|
133
|
+
|
|
134
|
+
Any exception raised while preparing the run is captured and turned into a test failure in :meth:`end_test` (a
|
|
135
|
+
status set here would otherwise be overwritten when the test body runs).
|
|
136
|
+
|
|
137
|
+
:param data: Running test case provided by Robot Framework.
|
|
138
|
+
:param result: Test result object provided by Robot Framework.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def collect_steps(data):
|
|
142
|
+
"""Collect the Gherkin steps from the running test case."""
|
|
143
|
+
collector = _GherkinStepCollector()
|
|
144
|
+
data.visit(collector)
|
|
145
|
+
return collector.steps
|
|
146
|
+
|
|
147
|
+
self._start_error = None
|
|
148
|
+
self._step_index = 0
|
|
149
|
+
try:
|
|
150
|
+
self.scenario = "\n".join(collect_steps(data))
|
|
151
|
+
url = self._search_variable(URL_VAR)
|
|
152
|
+
self.url = None if url is None else str(url)
|
|
153
|
+
self.context = self._search_context()
|
|
154
|
+
self._execute_lynqa_testrun(name=data.name)
|
|
155
|
+
except Exception as error:
|
|
156
|
+
logger.error(f"Lynqa run preparation failed: {error}")
|
|
157
|
+
self._start_error = error
|
|
158
|
+
|
|
159
|
+
def end_test(self, data, result) -> None:
|
|
160
|
+
"""Fail the current test if :meth:`start_test` caught an exception.
|
|
161
|
+
|
|
162
|
+
:param data: Running test case provided by Robot Framework.
|
|
163
|
+
:param result: Test result object provided by Robot Framework.
|
|
164
|
+
"""
|
|
165
|
+
if self._start_error is not None:
|
|
166
|
+
result.status = "FAIL"
|
|
167
|
+
result.message = f"Lynqa execution failed: {self._start_error}"
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
self._report_test_results(result)
|
|
171
|
+
|
|
172
|
+
def start_keyword(self, data, result) -> None:
|
|
173
|
+
"""Log the commands and assertions of a Gherkin keyword's Lynqa step.
|
|
174
|
+
|
|
175
|
+
Logging happens here, when the keyword starts, rather than in the keyword body: Robot Framework stops executing
|
|
176
|
+
the bodies of the keywords that follow a failed one, but :meth:`start_keyword` still fires for them, so every
|
|
177
|
+
Gherkin keyword gets its logs. The step is matched in execution order via :attr:`_step_index`, which is only
|
|
178
|
+
incremented later in :meth:`end_keyword`.
|
|
179
|
+
|
|
180
|
+
:param data: Running keyword provided by Robot Framework.
|
|
181
|
+
:param result: Keyword result object provided by Robot Framework.
|
|
182
|
+
"""
|
|
183
|
+
if data.name not in GHERKIN_KEYWORDS:
|
|
184
|
+
return
|
|
185
|
+
if self._reporter is not None:
|
|
186
|
+
self._reporter.log_step(self._step_index, self.results)
|
|
187
|
+
|
|
188
|
+
def end_keyword(self, data, result) -> None:
|
|
189
|
+
"""Set each Gherkin keyword's status from its matching Lynqa step.
|
|
190
|
+
|
|
191
|
+
Only the Gherkin step keywords are reported; other keywords (logging, setup, ...) keep the status Robot
|
|
192
|
+
Framework gave them.
|
|
193
|
+
|
|
194
|
+
:param data: Running keyword provided by Robot Framework.
|
|
195
|
+
:param result: Keyword result object provided by Robot Framework.
|
|
196
|
+
"""
|
|
197
|
+
if data.name not in GHERKIN_KEYWORDS:
|
|
198
|
+
return
|
|
199
|
+
steps = self.results.get("stepStatuses", [])
|
|
200
|
+
if self._step_index < len(steps):
|
|
201
|
+
step_status = steps[self._step_index].get("status")
|
|
202
|
+
else:
|
|
203
|
+
# No per-step detail: fall back to the overall run status.
|
|
204
|
+
step_status = self.results.get("status")
|
|
205
|
+
result.status = "PASS" if step_status == "success" else "FAIL"
|
|
206
|
+
self._step_index += 1
|
|
207
|
+
|
|
208
|
+
def _execute_lynqa_testrun(self, name: str, timeout: float = RUN_TIMEOUT_SECONDS):
|
|
209
|
+
"""Start a Lynqa test run and wait for it to reach a final status.
|
|
210
|
+
|
|
211
|
+
Polls the run status every :data:`POLL_INTERVAL_SECONDS` seconds until it leaves the :data:`PENDING_STATUSES`,
|
|
212
|
+
giving up after ``timeout`` seconds so the run cannot hang forever when a final status is never reached.
|
|
213
|
+
|
|
214
|
+
:param name: Name of the test run.
|
|
215
|
+
:param timeout: Maximum time, in seconds, to wait for the run to finish.
|
|
216
|
+
|
|
217
|
+
:raises TimeoutError: If the run is still pending once ``timeout`` elapses.
|
|
218
|
+
"""
|
|
219
|
+
if not self.url:
|
|
220
|
+
raise ValueError(f"No URL configured. Please set the {URL_VAR} variable.") # ruff: ignore[raise-vanilla-args]
|
|
221
|
+
|
|
222
|
+
self._run_id = self._client.add_gherkin_test_run(
|
|
223
|
+
url=self.url,
|
|
224
|
+
name=name,
|
|
225
|
+
scenario=self.scenario,
|
|
226
|
+
)
|
|
227
|
+
logger.info(f"Testrun ID is: {self._run_id}")
|
|
228
|
+
self._wait_until_testrun_end(timeout)
|
|
229
|
+
self.results = self._client.get_test_run_full_status(self._run_id)
|
|
230
|
+
self._reporter = StepReporter(self._client, self._run_id, self.url)
|
|
231
|
+
|
|
232
|
+
def _wait_until_testrun_end(self, timeout: float):
|
|
233
|
+
"""Wait until the test run reaches a final status.
|
|
234
|
+
|
|
235
|
+
Polls the run status every :data:`POLL_INTERVAL_SECONDS` seconds until it leaves the :data:`PENDING_STATUSES`.
|
|
236
|
+
|
|
237
|
+
:param timeout: Maximum time, in seconds, to wait for the run to finish.
|
|
238
|
+
|
|
239
|
+
:raises TimeoutError: If the run is still pending once ``timeout`` elapses.
|
|
240
|
+
"""
|
|
241
|
+
deadline = time.monotonic() + timeout
|
|
242
|
+
while True:
|
|
243
|
+
status = self._client.get_test_run_status(self._run_id).get("status")
|
|
244
|
+
logger.debug(f"Current run status: {status}")
|
|
245
|
+
if status not in PENDING_STATUSES:
|
|
246
|
+
return
|
|
247
|
+
if time.monotonic() >= deadline:
|
|
248
|
+
msg = f"Lynqa test run {self._run_id} did not complete within {timeout}s"
|
|
249
|
+
raise TimeoutError(msg)
|
|
250
|
+
time.sleep(POLL_INTERVAL_SECONDS)
|
|
251
|
+
|
|
252
|
+
def _report_test_results(self, result):
|
|
253
|
+
"""Report the test results from the Lynqa API.
|
|
254
|
+
|
|
255
|
+
Sets the test status to ``PASS`` when the run succeeded and ``FAIL`` otherwise, and copies the run's status
|
|
256
|
+
message onto the result.
|
|
257
|
+
|
|
258
|
+
:param result: Test result object provided by Robot Framework.
|
|
259
|
+
"""
|
|
260
|
+
if self.results.get("status") == "success":
|
|
261
|
+
result.status = "PASS"
|
|
262
|
+
else:
|
|
263
|
+
result.status = "FAIL"
|
|
264
|
+
|
|
265
|
+
result.message = self.results.get("statusMessage")
|
|
266
|
+
|
|
267
|
+
# ------------------------------------------------------------------
|
|
268
|
+
# Keywords exposed to Robot Framework
|
|
269
|
+
# ------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
@keyword
|
|
272
|
+
def given(self, text: str) -> None:
|
|
273
|
+
"""Create a ``Given`` step in the current scenario.
|
|
274
|
+
|
|
275
|
+
:param text: Natural-language description of the step.
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
@keyword
|
|
279
|
+
def when(self, text: str) -> None:
|
|
280
|
+
"""Create a ``When`` step in the current scenario.
|
|
281
|
+
|
|
282
|
+
:param text: Natural-language description of the step.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
@keyword
|
|
286
|
+
def then(self, text: str) -> None:
|
|
287
|
+
"""Create a ``Then`` step in the current scenario.
|
|
288
|
+
|
|
289
|
+
:param text: Natural-language description of the step.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
# Helpers
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
@staticmethod
|
|
297
|
+
def _search_variable(variable_name) -> object:
|
|
298
|
+
"""Return the value of a Robot Framework variable.
|
|
299
|
+
|
|
300
|
+
:param variable_name: Name of the variable to look up, e.g. ``${LYNQA_URL}``.
|
|
301
|
+
|
|
302
|
+
:returns: The variable's value, or ``None`` when it is not set.
|
|
303
|
+
"""
|
|
304
|
+
return BuiltIn().get_variable_value(variable_name)
|
|
305
|
+
|
|
306
|
+
def _search_secrets_variables(self) -> list[TestData]:
|
|
307
|
+
"""Convert the secrets variable into a list of :class:`TestData`.
|
|
308
|
+
|
|
309
|
+
The ``&{LYNQA_SECRETS}`` variable is expected to be a mapping of secret name to value, e.g. ``login=superu
|
|
310
|
+
password=TrèsS3cr3t``. Each entry is turned into a :class:`TestData` name/value pair.
|
|
311
|
+
|
|
312
|
+
:returns: The secrets as :class:`TestData` items, or an empty list if unset.
|
|
313
|
+
|
|
314
|
+
:raises TypeError: If the secrets variable is not a name/value mapping.
|
|
315
|
+
"""
|
|
316
|
+
raw_secrets = self._search_variable(SECRETS_VAR)
|
|
317
|
+
if not raw_secrets:
|
|
318
|
+
return []
|
|
319
|
+
if not isinstance(raw_secrets, dict):
|
|
320
|
+
raise TypeError("Invalid secrets variable, expected a dict") # ruff: ignore[raise-vanilla-args]
|
|
321
|
+
return [TestData(name=str(name), value=str(value)) for name, value in raw_secrets.items()]
|
|
322
|
+
|
|
323
|
+
def _search_context(self) -> TestRunContext:
|
|
324
|
+
"""Build the Lynqa run context from the ``LYNQA_*`` Robot Framework variables.
|
|
325
|
+
|
|
326
|
+
Reads the optional ``${LYNQA_LANGUAGE}`` and ``${LYNQA_DATETIME}`` variables and the ``&{LYNQA_SECRETS}``
|
|
327
|
+
mapping. When ``${LYNQA_DATETIME}`` is not set, the current local date and time is used.
|
|
328
|
+
|
|
329
|
+
:returns: The context passed to the Lynqa test run.
|
|
330
|
+
"""
|
|
331
|
+
client_language = self._search_variable(LANGUAGE_VAR)
|
|
332
|
+
client_datetime = self._search_variable(DATETIME_VAR)
|
|
333
|
+
if client_datetime is None:
|
|
334
|
+
client_datetime = datetime.now().astimezone().strftime("%a %b %d %Y %H:%M:%S GMT%z")
|
|
335
|
+
secrets = self._search_secrets_variables()
|
|
336
|
+
return TestRunContext(
|
|
337
|
+
client_language=str(client_language), client_datetime=str(client_datetime), secrets=secrets
|
|
338
|
+
)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Robot Framework log presentation for Lynqa test runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from robot.api import logger
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pylynqa import LynqaClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StepReporter:
|
|
14
|
+
"""Render the Robot log for one Lynqa test run step.
|
|
15
|
+
|
|
16
|
+
Holds the run data needed to embed screenshots and caption commands and assertions, and exposes :meth:`log_step` to
|
|
17
|
+
log a single step's details under the current keyword.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, client: LynqaClient, run_id: str, url: str | None) -> None:
|
|
21
|
+
"""Initialize the reporter for a single run.
|
|
22
|
+
|
|
23
|
+
:param client: Lynqa client used to fetch screenshots.
|
|
24
|
+
:param run_id: Identifier of the Lynqa test run.
|
|
25
|
+
:param url: URL the run was started against, shown with the initial screenshot.
|
|
26
|
+
"""
|
|
27
|
+
self._client = client
|
|
28
|
+
self._run_id = run_id
|
|
29
|
+
self._url = url
|
|
30
|
+
|
|
31
|
+
def log_step(self, step_index: int, results: dict) -> None:
|
|
32
|
+
"""Log the commands and assertions of a Gherkin step.
|
|
33
|
+
|
|
34
|
+
Logs a ``Command`` list with one log per command and an ``Assertion`` list with one log per assertion, each
|
|
35
|
+
followed by its screenshots if any. The initial screenshot captured before the run is embedded once, ahead of
|
|
36
|
+
the first step's commands. The step is the one at ``step_index`` in the run's ``stepStatuses``.
|
|
37
|
+
|
|
38
|
+
:param step_index: Index of the step to log, in execution order.
|
|
39
|
+
:param results: Full status of the run, as returned by the Lynqa API.
|
|
40
|
+
"""
|
|
41
|
+
steps = results.get("stepStatuses", [])
|
|
42
|
+
if step_index >= len(steps):
|
|
43
|
+
return
|
|
44
|
+
step = steps[step_index]
|
|
45
|
+
|
|
46
|
+
if step_index == 0:
|
|
47
|
+
self._log_initial_report(results)
|
|
48
|
+
self._log_step_commands(step)
|
|
49
|
+
self._log_step_assertions(step)
|
|
50
|
+
|
|
51
|
+
def _log_initial_report(self, results) -> None:
|
|
52
|
+
"""Log the initial report (global info).
|
|
53
|
+
|
|
54
|
+
:param results: Full status of the run, as returned by the Lynqa API.
|
|
55
|
+
"""
|
|
56
|
+
initial_report = results.get("initialReport", {})
|
|
57
|
+
logger.info(self._embed_screenshot(initial_report.get("screenshot"), f"Open URL {self._url}"), html=True)
|
|
58
|
+
|
|
59
|
+
def _log_step_commands(self, step: dict) -> None:
|
|
60
|
+
"""Log the current step's commands and embed their screenshots.
|
|
61
|
+
|
|
62
|
+
:param step: The step from the run's ``stepStatuses`` to log.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def _is_response_success(command):
|
|
66
|
+
return "success" in command.get("response", {})
|
|
67
|
+
|
|
68
|
+
for command in step.get("commands", []):
|
|
69
|
+
level = "INFO" if _is_response_success(command) else "ERROR"
|
|
70
|
+
logger.write(
|
|
71
|
+
self._embed_screenshot(command.get("screenshot"), self._command_caption(command)),
|
|
72
|
+
level=level,
|
|
73
|
+
html=True,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _command_caption(command: dict) -> str:
|
|
78
|
+
"""Build a human-readable caption for a command's screenshot.
|
|
79
|
+
|
|
80
|
+
:param command: A command entry from a step's ``commands`` list.
|
|
81
|
+
|
|
82
|
+
:returns: The command name enriched with available information.
|
|
83
|
+
"""
|
|
84
|
+
caption = f"Command: {command.get('name', '')}"
|
|
85
|
+
if "value" in command:
|
|
86
|
+
caption += f"(value: {command['value']})"
|
|
87
|
+
elif "button" in command:
|
|
88
|
+
caption += f"(button: {command['button']})"
|
|
89
|
+
if command.get("htmlElement"):
|
|
90
|
+
caption += f' on element "{command["htmlElement"]}"'
|
|
91
|
+
return str(caption)
|
|
92
|
+
|
|
93
|
+
def _log_step_assertions(self, step: dict) -> None:
|
|
94
|
+
"""Log the current step's assertions and embed the assertions report screenshot.
|
|
95
|
+
|
|
96
|
+
:param step: The step from the run's ``stepStatuses`` to log.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def _is_assertion_checked(assertion):
|
|
100
|
+
return assertion.get("checked", False)
|
|
101
|
+
|
|
102
|
+
report = step.get("assertionsReport", {})
|
|
103
|
+
assertions = report.get("assertions", [])
|
|
104
|
+
|
|
105
|
+
for assertion in assertions:
|
|
106
|
+
level = "INFO" if _is_assertion_checked(assertion) else "ERROR"
|
|
107
|
+
logger.write(
|
|
108
|
+
self._assertion_caption(assertion),
|
|
109
|
+
level=level,
|
|
110
|
+
html=True,
|
|
111
|
+
)
|
|
112
|
+
if "testVerdictCause" in step:
|
|
113
|
+
logger.error(f"Verdict: {step['testVerdictCause']}")
|
|
114
|
+
|
|
115
|
+
logger.info(self._embed_screenshot(report.get("screenshot"), ""), html=True)
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _assertion_caption(assertion):
|
|
119
|
+
"""Build a human-readable caption for an assertion.
|
|
120
|
+
|
|
121
|
+
:param assertion: An assertion entry from a step's ``assertionsReport`` assertions list.
|
|
122
|
+
|
|
123
|
+
:returns: The assertion text enriched with available information.
|
|
124
|
+
"""
|
|
125
|
+
caption = f'Assertion: "{assertion.get("assertion", "")}"'
|
|
126
|
+
return str(caption)
|
|
127
|
+
|
|
128
|
+
def _embed_screenshot(self, screenshot_id: str | None, caption: str) -> str:
|
|
129
|
+
"""Fetch a Lynqa screenshot and return it embedded inline in an HTML string.
|
|
130
|
+
|
|
131
|
+
Downloads the screenshot as base64-encoded PNG data and wraps it in an ``<img>`` inside a collapsible
|
|
132
|
+
``<details>`` block. A download failure is logged and caught, so it never fails the test run.
|
|
133
|
+
|
|
134
|
+
:param screenshot_id: UUID of the screenshot to fetch, or ``None`` when the step has none.
|
|
135
|
+
:param caption: Text shown above the embedded image.
|
|
136
|
+
|
|
137
|
+
:returns: The caption and embedded image as an HTML string, or an empty string when there is no screenshot or
|
|
138
|
+
the download failed.
|
|
139
|
+
"""
|
|
140
|
+
if not screenshot_id:
|
|
141
|
+
return ""
|
|
142
|
+
try:
|
|
143
|
+
data = self._client.get_screenshot(self._run_id, screenshot_id)
|
|
144
|
+
except Exception as error:
|
|
145
|
+
logger.error(f"Failed to fetch Lynqa screenshot {screenshot_id}: {error}")
|
|
146
|
+
return ""
|
|
147
|
+
return (
|
|
148
|
+
f'{caption}<details><summary>screenshot</summary><img src="data:image/png;base64,{data}"'
|
|
149
|
+
+ 'style="max-width: 100%;"/></details>'
|
|
150
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Unit tests for robotframework_lynqa."""
|
|
2
|
+
|
|
3
|
+
from robot.api.interfaces import ListenerV3
|
|
4
|
+
|
|
5
|
+
# ruff: file-ignore[non-empty-init-module]
|
|
6
|
+
from robot.libraries.BuiltIn import BuiltIn
|
|
7
|
+
|
|
8
|
+
API_KEY = "fake_api_key"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _CaptureLibInstance(ListenerV3):
|
|
12
|
+
"""Listener that captures the live LynqaLibrary instance during the run.
|
|
13
|
+
|
|
14
|
+
The ``.robot`` file owns the :class:`LynqaLibrary` instance (it is created by the ``Library`` import), so the test
|
|
15
|
+
cannot reference it directly. This listener grabs that instance from the running context before the test ends.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
"""Initialize the capture listener."""
|
|
20
|
+
self.instance = None
|
|
21
|
+
|
|
22
|
+
def end_test(self, data, result) -> None:
|
|
23
|
+
"""Capture the library instance while the execution context is alive.
|
|
24
|
+
|
|
25
|
+
:param data: Running test data passed by Robot Framework.
|
|
26
|
+
:param result: Test result object passed by Robot Framework.
|
|
27
|
+
"""
|
|
28
|
+
self.instance = BuiltIn().get_library_instance("robotframework_lynqa.LynqaLibrary")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Pytest configuration for tests."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from pylynqa.models import TestData, TestRunContext
|
|
8
|
+
|
|
9
|
+
SCREENSHOT_PNG_FILE = Path(__file__).parent / "data_set" / "screenshot.png"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def pytest_configure(config):
|
|
13
|
+
"""Pytest configuration tests."""
|
|
14
|
+
# Prevent pytest from collecting these model classes as test suites
|
|
15
|
+
# (their names start with "Test" but they are domain objects, not test cases).
|
|
16
|
+
TestData.__test__ = False # ty: ignore[unresolved-attribute]
|
|
17
|
+
TestRunContext.__test__ = False # ty: ignore[unresolved-attribute]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def screenshot_base64():
|
|
22
|
+
"""Return the sample screenshot as base64-encoded PNG data.
|
|
23
|
+
|
|
24
|
+
Shared by the library integration tests and the reporter unit tests as the fake payload returned by the mocked
|
|
25
|
+
``get_screenshot`` client call.
|
|
26
|
+
|
|
27
|
+
:returns: Base64-encoded contents of the sample PNG fixture.
|
|
28
|
+
"""
|
|
29
|
+
return base64.b64encode(SCREENSHOT_PNG_FILE.read_bytes()).decode()
|
|
Binary file
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
*** Settings ***
|
|
2
|
+
Library robotframework_lynqa.LynqaLibrary api_key=%{LYNQA_API_KEY}
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
*** Variables ***
|
|
6
|
+
${LYNQA_URL} https://www.super-u.ai
|
|
7
|
+
${LYNQA_LANGUAGE} rf-RF
|
|
8
|
+
&{LYNQA_SECRETS} login=superu password=TrèsS3cr3t
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
*** Test Cases ***
|
|
12
|
+
Browsing And Buying An AI Agent
|
|
13
|
+
Given the storefront is open at the home page
|
|
14
|
+
When the user searches for an autonomous testing agent
|
|
15
|
+
When the user filters the results to top-rated agents under 50 credits
|
|
16
|
+
Then a list of matching AI agents is displayed with their prices
|
|
17
|
+
Then the premium "Bobcat" agent is sold out because its success
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
*** Settings ***
|
|
2
|
+
Library DateTime
|
|
3
|
+
Library robotframework_lynqa.LynqaLibrary api_key=%{LYNQA_API_KEY}
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
*** Variables ***
|
|
7
|
+
${LYNQA_URL} https://www.super-u.ai
|
|
8
|
+
${LYNQA_LANGUAGE} rf-RF
|
|
9
|
+
${LYNQA_DATETIME} Wed May 8 2026 09:00:00 GMT+0200
|
|
10
|
+
&{LYNQA_SECRETS} login=superu password=TrèsS3cr3t
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
*** Test Cases ***
|
|
14
|
+
Browsing And Buying An AI Agent With Date
|
|
15
|
+
Given the storefront is open at the home page
|
|
16
|
+
When the user searches for an autonomous testing agent
|
|
17
|
+
Then a list of matching AI agents is displayed with their prices
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
*** Settings ***
|
|
2
|
+
Library robotframework_lynqa.LynqaLibrary api_key=%{LYNQA_API_KEY}
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
*** Variables ***
|
|
6
|
+
${LYNQA_URL} https://www.google.com/
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
*** Test Cases ***
|
|
10
|
+
Search Engine
|
|
11
|
+
Given go to the website
|
|
12
|
+
When I look at the search input
|
|
13
|
+
Then the search input exists
|
|
14
|
+
When I search for 'lynqa'
|
|
15
|
+
Then several results are displayed and the first results display 'Smartesting'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
*** Settings ***
|
|
2
|
+
Library DateTime
|
|
3
|
+
Library robotframework_lynqa.LynqaLibrary api_key=%{LYNQA_API_KEY}
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
*** Variables ***
|
|
7
|
+
${LYNQA_LANGUAGE} rf-RF
|
|
8
|
+
${LYNQA_DATETIME} Wed May 8 2026 09:00:00 GMT+0200
|
|
9
|
+
&{LYNQA_SECRETS} login=superu password=TrèsS3cr3t
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
*** Test Cases ***
|
|
13
|
+
Browsing And Buying An AI Agent Without URL
|
|
14
|
+
Given the storefront is open at the home page
|
|
15
|
+
When the user searches for an autonomous testing agent
|
|
16
|
+
Then a list of matching AI agents is displayed with their prices
|