Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Browser/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ whether its navigation committed, how far the document got, and which requests w
outstanding. It answers "did the page never load, or did the wait miss it?"
_Avoid_: load state (that is the `waitUntil` value a keyword waits for), load diagnostics

**JavaScript extension keyword**:
A keyword whose body is a function in a `jsextension` module. It is called like any other
keyword, over the **Robot Framework path** or the **Python path**, and a Python library built
on Browser can also call the function by name with `call_js_keyword`.
_Avoid_: JS keyword, extension function (that is the JavaScript side only)

**Injected argument**:
An argument of a **JavaScript extension keyword**'s function that the library supplies by
name instead of taking it from the caller: `page`, `context`, `browser`, `logger`,
`playwright`, `adoptContext` and `defaultTimeout`.
_Avoid_: reserved argument, API argument

## Relationships

- Both tables hold the *same* keyword methods, stored twice by PythonLibCore. They are
Expand All @@ -54,6 +66,10 @@ _Avoid_: load state (that is the `waitUntil` value a keyword waits for), load di
- A **load report** belongs to a page, not to a keyword: every keyword that waits for a page to
load gives one when that wait times out. When `New Page` times out, it is the report of the
**failed page**.
- **Injected arguments** are filled however a **JavaScript extension keyword** is called.
Only `defaultTimeout` yields to a caller, and only to a library calling the function by name
with `call_js_keyword`. Called as a keyword, it always gets the library timeout, which a test
changes with `Set Browser Timeout`.

## Example dialogue

Expand Down
69 changes: 29 additions & 40 deletions Browser/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,12 +516,15 @@ def __init__( # noqa: PLR0915
argument names ``page``, ``context``, ``browser``, ``logger``,
``playwright``, ``adoptContext`` and ``defaultTimeout`` are filled in by
the library rather than taken from the keyword call. ``defaultTimeout``
is the library timeout in milliseconds. ``adoptContext(context, options)``
makes a BrowserContext that the function created itself, such as the
context of an Electron app, the active browser, so the other keywords
work on it. Can be a single path, a comma-separated list of paths or a
real list of strings. The options of ``adoptContext`` and examples are
in https://robotframework-browser.org/docs/extending/javascript-extensions
is the library timeout in milliseconds, as `Set Browser Timeout` sets
it; only a Python library that calls the function with
``call_js_keyword`` may pass its own instead.
``adoptContext(context, options)`` makes a BrowserContext that the
function created itself, such as the context of an Electron app, the
active browser, so the other keywords work on it. Can be a single path,
a comma-separated list of paths or a real list of strings. The options
of ``adoptContext`` and examples are in
https://robotframework-browser.org/docs/extending/javascript-extensions
- ``language``: Defines language which is used to translate keyword names and
documentation.
- ``playwright_process_host``: Hostname / Host address which should be used
Expand Down Expand Up @@ -815,15 +818,15 @@ def _jskeyword_call(
if arg
]
argument_names_and_default_values_texts = []
arg_set_texts = []
call_argument_texts = []
for item in argument_names_and_vals:
arg_name = item[0]
if arg_name in self._js_injected_arguments:
arg_set_texts.append(f'("{arg_name}", "RESERVED")')
call_argument_texts.append(f"{arg_name}=None")
elif arg_name == "defaultTimeout":
arg_set_texts.append('("defaultTimeout", int(self.timeout))')
continue
else:
arg_set_texts.append(f'("{arg_name}", {arg_name})')
call_argument_texts.append(f"{arg_name}={arg_name}")
if arg_name == "args":
argument_names_and_default_values_texts.append("*args")
elif len(item) > 1:
Expand All @@ -836,31 +839,12 @@ def _jskeyword_call(
@keyword
def {name}(self, {", ".join(argument_names_and_default_values_texts)}):
\"\"\"{doc}\"\"\"
_args_browser_internal = dict()
_args_browser_internal["arguments"] = [{", ".join(arg_set_texts)}]
with self.playwright.grpc_channel() as stub:
responses = stub.CallExtensionKeyword(
Request().KeywordCall(name="{name}", arguments=json.dumps(_args_browser_internal))
)
body_parts: list[str] = []
last_json = ""
for response in responses:
logger.info(response.log)
if response.bodyPart:
body_parts.append(response.bodyPart)
if response.json:
last_json = response.json
if body_parts:
body = "".join(body_parts)
return json.loads(body)
if not last_json:
return
return json.loads(last_json)
return self.library.call_js_keyword("{name}", {", ".join(call_argument_texts)})
"""
try:
exec(
text,
{**globals(), "keyword": keyword, "json": json},
{"keyword": keyword},
component.__dict__,
)
setattr(
Expand All @@ -869,7 +853,18 @@ def {name}(self, {", ".join(argument_names_and_default_values_texts)}):
except SyntaxError as e:
raise DataError(f"{e.msg} in {name}")

def call_js_keyword(self, keyword_name: str, **args) -> Any:
def call_js_keyword(self, keyword_name: str, /, **args) -> Any:
"""Calls the function ``keyword_name`` of a ``jsextension`` module and returns
what it returns.

``args`` are matched to the function's arguments by name; arguments that the
function does not declare are ignored. The library fills in ``page``,
``context``, ``browser``, ``logger``, ``playwright`` and ``adoptContext``,
whatever ``args`` gives for them. ``defaultTimeout`` is the library timeout in
milliseconds unless ``args`` gives one, for example when a library keeps a
timeout of its own.
"""
args.setdefault("defaultTimeout", int(self.timeout))
_args_browser_internal = {
"arguments": [
(
Expand All @@ -886,19 +881,13 @@ def call_js_keyword(self, keyword_name: str, **args) -> Any:
)
)
body_parts: list[str] = []
last_json = ""
for response in responses:
logger.info(response.log)
if response.bodyPart:
body_parts.append(response.bodyPart)
if response.json:
last_json = response.json
if body_parts:
body = "".join(body_parts)
return json.loads(body)
if not last_json:
if not body_parts:
return None
return json.loads(last_json)
return json.loads("".join(body_parts))

def adopted_context_options(
self,
Expand Down
45 changes: 41 additions & 4 deletions utest/test_js_extension_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def library(tmpdir, monkeypatch, playwright: RecordingPlaywright) -> Browser:


def extension_response(log: str, body_part: str = "") -> SimpleNamespace:
return SimpleNamespace(log=log, bodyPart=body_part, json="")
return SimpleNamespace(log=log, bodyPart=body_part)


def generated_keyword(library: Browser, name: str, arguments: str):
Expand Down Expand Up @@ -79,17 +79,21 @@ def test_generated_keyword_reads_the_library_timeout_at_each_call(
assert playwright.payload() == {"arguments": [["defaultTimeout", 3000]]}


def test_other_generated_keywords_send_only_their_arguments(
def test_generated_keyword_sends_injected_arguments_as_reserved(
library: Browser, playwright: RecordingPlaywright
):
generated_keyword(library, "clickTwice", "selector, page")("#button")

assert playwright.payload() == {
"arguments": [["selector", "#button"], ["page", "RESERVED"]]
"arguments": [
["selector", "#button"],
["page", "RESERVED"],
["defaultTimeout", 7000],
]
}


def test_call_js_keyword_sends_the_arguments_as_given(
def test_call_js_keyword_keeps_the_default_timeout_its_caller_passes(
library: Browser, playwright: RecordingPlaywright
):
library.call_js_keyword("openOwnContext", url="https://a.b", defaultTimeout=5000)
Expand All @@ -99,6 +103,38 @@ def test_call_js_keyword_sends_the_arguments_as_given(
}


def test_call_js_keyword_fills_in_the_library_timeout_when_it_is_left_out(
library: Browser, playwright: RecordingPlaywright
):
library.call_js_keyword("openOwnContext", url="https://a.b")

assert playwright.payload() == {
"arguments": [["url", "https://a.b"], ["defaultTimeout", 7000]]
}


def test_call_js_keyword_ignores_a_value_given_for_an_injected_argument(
library: Browser, playwright: RecordingPlaywright
):
library.call_js_keyword("clickTwice", selector="#button", page="my own page")

assert playwright.payload() == {
"arguments": [
["selector", "#button"],
["page", "RESERVED"],
["defaultTimeout", 7000],
]
}


def test_generated_keyword_sends_an_argument_named_keyword_name(
library: Browser, playwright: RecordingPlaywright
):
generated_keyword(library, "lookUp", "keyword_name")("Click")

assert playwright.payload()["arguments"][0] == ["keyword_name", "Click"]


def test_generated_keyword_joins_the_body_parts_of_a_chunked_result(
library: Browser, playwright: RecordingPlaywright
):
Expand Down Expand Up @@ -132,6 +168,7 @@ def test_generated_keyword_sends_its_varargs_as_one_args_list(
["selector", "#field"],
["args", ["first", "second"]],
["logger", "RESERVED"],
["defaultTimeout", 7000],
]
}

Expand Down
Loading