Skip to content

Commit 9da926c

Browse files
committed
feat: creating test that prove new Python usage documentation
These new test proves and documents library usage when libary is extended from Python. Use to verify documentation to be written in robotframework-browser.org
1 parent 826cc2b commit 9da926c

13 files changed

Lines changed: 714 additions & 13 deletions

atest/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@ Robot Framework acceptance tests for the Browser library.
66

77
```
88
atest/
9-
├── test/ # Test suites (00–12 numbered by feature area)
10-
│ └── __init__.robot # Suite setup/teardown + global library imports
9+
├── test/ # Test suites (00–13 numbered by feature area)
10+
│ ├── __init__.robot # Suite setup/teardown + global library imports
11+
│ └── 13_Python_Extension/ # Demonstrates using Browser from a user's own Python library.
12+
│ └── _child/ # Suites run as child processes. Robot Framework skips "_" directories
13+
│ # when walking, so these do not run in the normal suite collection.
1114
├── library/ # Python helper libraries
1215
│ ├── common.py # Test server lifecycle (start/stop, log capture)
16+
│ ├── child_result.py # Assertions about a child robot run's output.xml and playwright-log
1317
│ └── test_app_listener.py # RF listener → test-app /api/log/context
1418
└── output/ # Generated by test runs (gitignored)
1519
└── test-app/ # Per-process newline-delimited JSON logs

atest/library/child_result.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Assertions about what a child Robot Framework run did.
2+
3+
Used by atest/test/13_Python_Extension/python_extension.robot, which starts
4+
child `robot` runs and then verifies their results. This lives here rather than
5+
next to those suites on purpose: the files under 13_Python_Extension/ are
6+
quoted by path in the documentation on robotframework-browser.org and must stay
7+
free of test scaffolding.
8+
"""
9+
10+
import json
11+
import re
12+
from pathlib import Path
13+
14+
from robot.api import logger
15+
from robot.result import ExecutionResult, Result, TestCase
16+
17+
18+
def get_child_result(output_xml: Path) -> Result:
19+
"""Parse the output.xml of a child run."""
20+
result = ExecutionResult(str(output_xml))
21+
logger.info(f"Parsed {output_xml}")
22+
return result
23+
24+
25+
def child_test_status_should_be(
26+
result: Result,
27+
name: str,
28+
status: str,
29+
message_pattern: str | None = None,
30+
):
31+
"""Verify status, and optionally the failure message, of a child run test."""
32+
test = _get_test(result, name)
33+
assert test.status == status, (
34+
f"Test '{name}' status was '{test.status}', expected '{status}'. "
35+
f"Message: {test.message}"
36+
)
37+
if message_pattern is not None:
38+
assert re.search(message_pattern, test.message), (
39+
f"Test '{name}' message '{test.message}' does not match '{message_pattern}'"
40+
)
41+
42+
43+
def child_keyword_should_log(
44+
result: Result,
45+
test_name: str,
46+
owner: str,
47+
message_pattern: str,
48+
):
49+
"""Verify a message was logged inside a keyword owned by the given library.
50+
51+
Browser keywords called from Python do not appear in output.xml as keywords
52+
of their own, but everything they log lands inside the keyword of the
53+
library that called them.
54+
"""
55+
messages = []
56+
for keyword in _get_keywords(_get_test(result, test_name)):
57+
if keyword.owner != owner:
58+
continue
59+
for message in keyword.messages:
60+
messages.append(message.message)
61+
if re.search(message_pattern, message.message):
62+
logger.info(
63+
f"Found '{message_pattern}' in keyword "
64+
f"'{keyword.full_name}': {message.message}"
65+
)
66+
return
67+
raise AssertionError(
68+
f"No message matching '{message_pattern}' logged by a '{owner}' keyword "
69+
f"in test '{test_name}'. Messages: {messages}"
70+
)
71+
72+
73+
def child_should_not_use_library(result: Result, library: str):
74+
"""Verify the child run did not call any keyword of the given library.
75+
76+
Guards context B: if a child suite ever imports Browser as a Robot
77+
Framework library, the listener registers and the context collapses into
78+
context A without any test failing.
79+
"""
80+
for test in result.suite.tests:
81+
for keyword in _get_keywords(test):
82+
assert keyword.owner != library, (
83+
f"Test '{test.name}' called '{keyword.full_name}' "
84+
f"from library '{library}'"
85+
)
86+
87+
88+
def playwright_log_should_have_rf_context(log_file: Path, expected: str):
89+
"""Verify node side log records carry the Robot Framework test name."""
90+
test_names = _logged_test_names(log_file)
91+
assert any(expected in name for name in test_names), (
92+
f"No node side log record with test name containing '{expected}'. "
93+
f"Found: {sorted(test_names)}"
94+
)
95+
96+
97+
def playwright_log_should_not_have_rf_context(log_file: Path):
98+
"""Verify node side log records carry no Robot Framework test name."""
99+
test_names = _logged_test_names(log_file)
100+
assert not test_names, (
101+
f"Node side log records unexpectedly carry test names: {sorted(test_names)}"
102+
)
103+
104+
105+
def _logged_test_names(log_file: Path) -> set:
106+
log_file = Path(log_file)
107+
assert log_file.is_file(), (
108+
f"{log_file} not found. The child run did not start its own node process."
109+
)
110+
test_names = set()
111+
for raw_line in log_file.read_text(encoding="utf-8", errors="replace").splitlines():
112+
line = raw_line.strip()
113+
if not line.startswith("{"):
114+
continue
115+
try:
116+
record = json.loads(line)
117+
except json.JSONDecodeError:
118+
continue
119+
if isinstance(record, dict) and record.get("test_name"):
120+
test_names.add(record["test_name"])
121+
logger.info(f"Test names in {log_file}: {sorted(test_names)}")
122+
return test_names
123+
124+
125+
def _get_test(result: Result, name: str) -> TestCase:
126+
for test in result.suite.tests:
127+
if test.name == name:
128+
return test
129+
names = [test.name for test in result.suite.tests]
130+
raise AssertionError(f"Test '{name}' not found from child run. Found: {names}")
131+
132+
133+
def _get_keywords(item) -> list:
134+
keywords = []
135+
for child in item.body:
136+
if child.type == child.KEYWORD:
137+
keywords.append(child)
138+
keywords.extend(_get_keywords(child))
139+
return keywords

atest/library/os_wrapper.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,35 @@ def get_enty_command() -> str:
6767
return f"{sys.executable} -m Browser.entry"
6868

6969

70+
def get_robot_command() -> str:
71+
"""Return the command that starts a child Robot Framework run."""
72+
return f"{sys.executable} -m robot"
73+
74+
75+
def get_child_robot_environment() -> dict:
76+
"""Return the environment for a child robot run that starts its own node process.
77+
78+
`inv atest` starts one shared node process for the whole run and exports
79+
ROBOT_FRAMEWORK_BROWSER_NODE_PORT and DEBUG for it (tasks.py). A child run
80+
that inherited those would connect to the shared process instead of
81+
starting its own and would never write its own playwright-log.txt.
82+
The pino log level is set rather than inherited so the child behaves the
83+
same under `inv atest` and `inv atest-robot`.
84+
"""
85+
env = os.environ.copy()
86+
env.pop("ROBOT_FRAMEWORK_BROWSER_NODE_PORT", None)
87+
env.pop("DEBUG", None)
88+
env["ROBOT_FRAMEWORK_BROWSER_PINO_LOG_LEVEL"] = "debug"
89+
return env
90+
91+
92+
def count_files(path: str, pattern: str) -> int:
93+
"""Return count of files matching pattern, also when path does not exist."""
94+
files = sorted(str(file) for file in Path(path).glob(pattern))
95+
logger.info(f'Files matching "{pattern}" in "{path}": {files}')
96+
return len(files)
97+
98+
7099
def verify_translation(filename: Path) -> dict:
71100
"""Verifies translation file."""
72101
with filename.open("r") as file:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
*** Settings ***
2+
Documentation Every test here starts a child `robot` run, which starts its own node process
3+
... and browser. That does not fit the 30 second default test timeout.
4+
5+
Test Timeout 3 minutes
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Context A: Robot Framework imports Browser, this library sits alongside it.
2+
3+
Demonstration library for the Browser documentation. It is quoted by path on
4+
robotframework-browser.org, so it deliberately contains no business logic and
5+
nothing that is not part of the example.
6+
7+
See MyLibraryB.py for context B, where the library owns the Browser instance.
8+
"""
9+
10+
import functools
11+
12+
from robot.libraries.BuiltIn import BuiltIn
13+
14+
from Browser import Browser
15+
16+
17+
def screenshot_on_failure(keyword):
18+
"""Take a screenshot when the wrapped keyword fails, then re-raise.
19+
20+
Browser runs its own ``run_on_failure`` from the Robot Framework dynamic
21+
library API, which is not entered when a keyword is called from Python.
22+
A small library can get equivalent behaviour with a decorator like this.
23+
Larger libraries usually intercept in one place instead, either by being a
24+
dynamic library themselves or by using PythonLibCore.
25+
"""
26+
27+
@functools.wraps(keyword)
28+
def wrapper(self, *args, **kwargs):
29+
try:
30+
return keyword(self, *args, **kwargs)
31+
except Exception:
32+
self.browser.take_screenshot("my-library-failure-{index}")
33+
raise
34+
35+
return wrapper
36+
37+
38+
class MyLibraryA:
39+
"""Business logic in Python, with Browser still owned by Robot Framework."""
40+
41+
ROBOT_LIBRARY_SCOPE = "GLOBAL"
42+
43+
def __init__(self):
44+
self._browser: Browser | None = None
45+
46+
@property
47+
def browser(self) -> Browser:
48+
"""The Browser instance that Robot Framework imported.
49+
50+
Looked up on first use rather than in ``__init__``, because Robot
51+
Framework may not have imported Browser yet when this library is
52+
constructed.
53+
"""
54+
if self._browser is None:
55+
self._browser = BuiltIn().get_library_instance("Browser")
56+
return self._browser
57+
58+
def open_login_page(self, url: str):
59+
self.browser.new_browser(headless=True)
60+
self.browser.new_page(url)
61+
62+
def click_heading_with_middle_mouse_button(self):
63+
"""Call Browser with plain Python values.
64+
65+
``"middle"`` becomes a ``MouseButton``, ``"2 seconds"`` becomes a
66+
``timedelta``, and the ``None`` stays ``None`` instead of turning into
67+
the string ``"None"``.
68+
"""
69+
self.browser.wait_for_elements_state("id=heading1", "visible", "2 seconds")
70+
self.browser.click("id=heading1", "middle")
71+
return self.browser.evaluate_javascript(None, "() => 'evaluated'")
72+
73+
def get_browser_output_directory(self) -> str:
74+
return self.browser.outputdir
75+
76+
def get_heading_with_validate_and_then(self) -> list:
77+
validated = self.browser.get_text(
78+
"id=heading1", "validate", "value == 'Login Page'"
79+
)
80+
transformed = self.browser.get_text("id=heading1", "then", "value.upper()")
81+
return [validated, transformed]
82+
83+
def set_test_scoped_browser_timeout(self, timeout: str) -> str:
84+
return self.browser.set_browser_timeout(timeout, "Test")
85+
86+
def set_browser_timeout_and_return_previous(self, timeout: str) -> str:
87+
return self.browser.set_browser_timeout(timeout)
88+
89+
def get_open_page_count(self) -> int:
90+
return sum(
91+
len(context["pages"])
92+
for browser in self.browser.get_browser_catalog()
93+
for context in browser["contexts"]
94+
)
95+
96+
def click_missing_element(self):
97+
self.browser.click("id=this_element_does_not_exist")
98+
99+
@screenshot_on_failure
100+
def click_missing_element_with_screenshot(self):
101+
self.browser.click("id=this_element_does_not_exist")
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Context B: Robot Framework imports only this library, which owns Browser.
2+
3+
Demonstration library for the Browser documentation. It is quoted by path on
4+
robotframework-browser.org, so it deliberately contains no business logic and
5+
nothing that is not part of the example.
6+
7+
MyLibraryB_no_listener.py is the same library without the listener
8+
registration, and the difference between the two is what the registration buys.
9+
"""
10+
11+
from Browser import Browser
12+
13+
14+
class MyLibraryB:
15+
"""Browser used as a base, with this library owning the instance."""
16+
17+
ROBOT_LIBRARY_SCOPE = "GLOBAL"
18+
# Robot Framework resolves the listener API version of every listener
19+
# separately. Browser declares version 2, but this library would default to
20+
# version 3, which has different method signatures.
21+
ROBOT_LISTENER_API_VERSION = 2
22+
23+
def __init__(self):
24+
self._browser = Browser(enable_playwright_debug=True)
25+
# Robot Framework accepts a list of listeners and calls Browser's
26+
# listener methods itself. Without this line Browser gets no suite and
27+
# test events, so automatic closing and scope settings do not work.
28+
self.ROBOT_LIBRARY_LISTENER = [self, self._browser]
29+
30+
def open_login_page(self, url: str):
31+
self._browser.new_browser(headless=True)
32+
self._browser.new_page(url)
33+
34+
def click_heading_with_middle_mouse_button(self):
35+
"""Call Browser with plain Python values.
36+
37+
``"middle"`` becomes a ``MouseButton``, ``"2 seconds"`` becomes a
38+
``timedelta``, and the ``None`` stays ``None`` instead of turning into
39+
the string ``"None"``.
40+
"""
41+
self._browser.wait_for_elements_state("id=heading1", "visible", "2 seconds")
42+
self._browser.click("id=heading1", "middle")
43+
return self._browser.evaluate_javascript(None, "() => 'evaluated'")
44+
45+
def get_browser_output_directory(self) -> str:
46+
return self._browser.outputdir
47+
48+
def get_heading_with_validate_and_then(self) -> list:
49+
validated = self._browser.get_text(
50+
"id=heading1", "validate", "value == 'Login Page'"
51+
)
52+
transformed = self._browser.get_text("id=heading1", "then", "value.upper()")
53+
return [validated, transformed]
54+
55+
def set_test_scoped_browser_timeout(self, timeout: str) -> str:
56+
return self._browser.set_browser_timeout(timeout, "Test")
57+
58+
def set_browser_timeout_and_return_previous(self, timeout: str) -> str:
59+
return self._browser.set_browser_timeout(timeout)
60+
61+
def get_open_page_count(self) -> int:
62+
return sum(
63+
len(context["pages"])
64+
for browser in self._browser.get_browser_catalog()
65+
for context in browser["contexts"]
66+
)
67+
68+
def click_missing_element(self):
69+
self._browser.click("id=this_element_does_not_exist")
70+
71+
def close_browser_library(self):
72+
self._browser.close_browser("ALL")

0 commit comments

Comments
 (0)