diff --git a/Browser/CONTEXT.md b/Browser/CONTEXT.md index 7a2b744e0..299aa4e4c 100644 --- a/Browser/CONTEXT.md +++ b/Browser/CONTEXT.md @@ -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 @@ -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 diff --git a/Browser/browser.py b/Browser/browser.py index 0f0c90e59..1b63cdaa2 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -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 @@ -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: @@ -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( @@ -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": [ ( @@ -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, diff --git a/utest/test_js_extension_call.py b/utest/test_js_extension_call.py index 4fe16bf57..c0666e756 100644 --- a/utest/test_js_extension_call.py +++ b/utest/test_js_extension_call.py @@ -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): @@ -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) @@ -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 ): @@ -132,6 +168,7 @@ def test_generated_keyword_sends_its_varargs_as_one_args_list( ["selector", "#field"], ["args", ["first", "second"]], ["logger", "RESERVED"], + ["defaultTimeout", 7000], ] }