From eb3cb989527c8e30a4a309f0d177d517777c8767 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sat, 17 Oct 2026 17:28:26 +0300 Subject: [PATCH 1/4] refactor: make the generated JavaScript extension keyword call call_js_keyword The generated keyword copied the streaming and decoding code of call_js_keyword. It now passes its arguments to call_js_keyword, so both routes send and read the same way. The json branch is dropped: Node returns every extension result as bodyPart chunks and never sets json. Co-Authored-By: Claude Opus 5.5 --- Browser/browser.py | 41 ++++++++--------------------------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/Browser/browser.py b/Browser/browser.py index 0f0c90e59..10f761c6a 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -815,15 +815,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))') + call_argument_texts.append("defaultTimeout=int(self.timeout)") 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 +836,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( @@ -886,19 +867,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, From 794d6e453d50ef76822cb139528ef846a2ede0d1 Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sat, 17 Oct 2026 17:29:44 +0300 Subject: [PATCH 2/4] feat: fill defaultTimeout in call_js_keyword when the caller leaves it out A Python library that calls a JavaScript extension function with call_js_keyword now gets the library timeout as defaultTimeout, as a generated keyword does. A value the caller passes still wins, for a library that keeps a timeout of its own. call_js_keyword gets a docstring and the jsextension docstring says who fills defaultTimeout. Co-Authored-By: Claude Opus 5.5 --- Browser/browser.py | 24 ++++++++++++++++++------ utest/test_js_extension_call.py | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/Browser/browser.py b/Browser/browser.py index 10f761c6a..991714df6 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -516,11 +516,12 @@ 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 + is the library timeout in milliseconds; 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. @@ -821,7 +822,7 @@ def _jskeyword_call( if arg_name in self._js_injected_arguments: call_argument_texts.append(f"{arg_name}=None") elif arg_name == "defaultTimeout": - call_argument_texts.append("defaultTimeout=int(self.timeout)") + continue else: call_argument_texts.append(f"{arg_name}={arg_name}") if arg_name == "args": @@ -851,6 +852,17 @@ def {name}(self, {", ".join(argument_names_and_default_values_texts)}): raise DataError(f"{e.msg} in {name}") 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": [ ( diff --git a/utest/test_js_extension_call.py b/utest/test_js_extension_call.py index 4fe16bf57..0bf9d848b 100644 --- a/utest/test_js_extension_call.py +++ b/utest/test_js_extension_call.py @@ -79,13 +79,17 @@ 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], + ] } @@ -99,6 +103,30 @@ 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_joins_the_body_parts_of_a_chunked_result( library: Browser, playwright: RecordingPlaywright ): @@ -132,6 +160,7 @@ def test_generated_keyword_sends_its_varargs_as_one_args_list( ["selector", "#field"], ["args", ["first", "second"]], ["logger", "RESERVED"], + ["defaultTimeout", 7000], ] } From a5cdeea2b5448ab5ad51f71a92f4b862e130653f Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sat, 17 Oct 2026 17:29:44 +0300 Subject: [PATCH 3/4] docs: name JavaScript extension keywords and injected arguments Co-Authored-By: Claude Opus 5.5 --- Browser/CONTEXT.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Browser/CONTEXT.md b/Browser/CONTEXT.md index 7a2b744e0..963f3b149 100644 --- a/Browser/CONTEXT.md +++ b/Browser/CONTEXT.md @@ -41,6 +41,17 @@ 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. Robot Framework calls it as a +generated keyword; a Python library built on Browser calls it 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 +65,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 on both routes into a **JavaScript extension keyword**. + Only `defaultTimeout` yields to a caller: a Python caller of `call_js_keyword` may pass its + own value, which wins over the library timeout. A generated keyword has no `defaultTimeout` + argument, so it always sends the library timeout. ## Example dialogue From efc2897f779c70475f4c904bc523730b6e11ae1f Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Sat, 17 Oct 2026 17:34:38 +0300 Subject: [PATCH 4/4] fix: let a JavaScript extension function take an argument named keyword_name The generated keyword passes its arguments to call_js_keyword by name, so an argument named keyword_name clashed with call_js_keyword's own first parameter. It is now positional-only. The jsextension docstring says a generated keyword always sends the library timeout, and the glossary uses the context's own terms for the two routes. Co-Authored-By: Claude Opus 5.5 --- Browser/CONTEXT.md | 13 +++++++------ Browser/browser.py | 12 +++++++----- utest/test_js_extension_call.py | 12 ++++++++++-- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/Browser/CONTEXT.md b/Browser/CONTEXT.md index 963f3b149..299aa4e4c 100644 --- a/Browser/CONTEXT.md +++ b/Browser/CONTEXT.md @@ -42,8 +42,9 @@ 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. Robot Framework calls it as a -generated keyword; a Python library built on Browser calls it by name with `call_js_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**: @@ -65,10 +66,10 @@ _Avoid_: reserved argument, API argument - 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 on both routes into a **JavaScript extension keyword**. - Only `defaultTimeout` yields to a caller: a Python caller of `call_js_keyword` may pass its - own value, which wins over the library timeout. A generated keyword has no `defaultTimeout` - argument, so it always sends the library timeout. +- **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 991714df6..1b63cdaa2 100755 --- a/Browser/browser.py +++ b/Browser/browser.py @@ -516,13 +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; a Python library that calls the - function with ``call_js_keyword`` may pass its own instead. + 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 + 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 @@ -851,7 +853,7 @@ 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. diff --git a/utest/test_js_extension_call.py b/utest/test_js_extension_call.py index 0bf9d848b..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): @@ -93,7 +93,7 @@ def test_generated_keyword_sends_injected_arguments_as_reserved( } -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) @@ -127,6 +127,14 @@ def test_call_js_keyword_ignores_a_value_given_for_an_injected_argument( } +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 ):