diff --git a/docs/src/features/sessions/browser-controls.mdx b/docs/src/features/sessions/browser-controls.mdx index 1c0a3f05e..fc54f309d 100644 --- a/docs/src/features/sessions/browser-controls.mdx +++ b/docs/src/features/sessions/browser-controls.mdx @@ -194,7 +194,7 @@ Press a keyboard key. ### EvaluateJs -Evaluate JavaScript code on the current page and return the result. +Evaluate JavaScript code on the current page and return the result. `session.evaluate_js(code)` returns the evaluated string directly (objects and arrays as JSON); a failing script raises with the actual JavaScript error. diff --git a/docs/src/sdk-reference/misc/evaluatejsaction.mdx b/docs/src/sdk-reference/misc/evaluatejsaction.mdx index 32b419cae..cf67fb76f 100644 --- a/docs/src/sdk-reference/misc/evaluatejsaction.mdx +++ b/docs/src/sdk-reference/misc/evaluatejsaction.mdx @@ -16,11 +16,17 @@ You will not get any output from console.log(), so simply use the return value i **Example:** ```python -session.execute(type="evaluate_js", code="document.title") -session.execute(type="evaluate_js", code="Array.from(document.querySelectorAll('a')).map(a => a.href)") -session.execute(type="evaluate_js", code="(() => { const els = document.querySelectorAll('a'); return els.length; })()") +title = session.evaluate_js("document.title") +links = session.evaluate_js("Array.from(document.querySelectorAll('a')).map(a => a.href)") +count = session.evaluate_js("(() => { const els = document.querySelectorAll('a'); return els.length; })()") ``` +`session.evaluate_js(code)` returns the evaluated string directly (objects and +arrays as JSON, a JS `null` as the string `"null"`) and raises on failure with +the actual JavaScript error. The action form +`session.execute(type="evaluate_js", code=...)` returns the `ExecutionResult` +envelope instead + ## Fields diff --git a/docs/src/sdk-reference/misc/remotesession.mdx b/docs/src/sdk-reference/misc/remotesession.mdx index db4aa6ece..c0126d86c 100644 --- a/docs/src/sdk-reference/misc/remotesession.mdx +++ b/docs/src/sdk-reference/misc/remotesession.mdx @@ -52,6 +52,20 @@ Debug information for the session. --- +### evaluate_js + +```python +evaluate_js(code: , raise_on_failure: = True) -> str | notte_core.browser.observation.ExecutionResult +``` + +Evaluate JavaScript on the current page and return its result as a string + +**Returns:** + +`UnionType`[`str`, [`ExecutionResult`](/sdk-reference/misc/executionresult)[`ExecutionResult`](/sdk-reference/misc/executionresult.md)] + +--- + ### execute ```python diff --git a/docs/src/sdk-reference/remotesession/evaluate_js.mdx b/docs/src/sdk-reference/remotesession/evaluate_js.mdx new file mode 100644 index 000000000..2bf66dd60 --- /dev/null +++ b/docs/src/sdk-reference/remotesession/evaluate_js.mdx @@ -0,0 +1,28 @@ +--- +title: "evaluate_js" +description: "Evaluate JavaScript on the current page and return its result as a string" +--- + + + +The result is stringified the way `evaluate_js` records it: objects and +arrays as JSON, a JS `null` as the string `"null"`. On failure the typed +exception is raised with the actual JavaScript error; pass +`raise_on_failure=False` to get the `ExecutionResult` envelope instead. + +```python +payload = json.loads(session.evaluate_js("(async () => JSON.stringify(await res.json()))()")) +``` + + +## Parameters + + + + + + + +## Returns + +`UnionType`[`str`, [`ExecutionResult`](/sdk-reference/misc/executionresult)[`ExecutionResult`](/sdk-reference/misc/executionresult.md)] diff --git a/docs/src/sdk-reference/remotesession/index.mdx b/docs/src/sdk-reference/remotesession/index.mdx index a15c7d70f..3d92d99ea 100644 --- a/docs/src/sdk-reference/remotesession/index.mdx +++ b/docs/src/sdk-reference/remotesession/index.mdx @@ -73,6 +73,24 @@ Attributes: Get detailed debug information for the session + + + Evaluate JavaScript on the current page and return its result as a string + + + + + Evaluate JavaScript on the current page and return its result as a string + + a.href)")) ``` diff --git a/docs/src/testers/browser-controls/eval_js.py b/docs/src/testers/browser-controls/eval_js.py index 98e66c65d..6445b7776 100644 --- a/docs/src/testers/browser-controls/eval_js.py +++ b/docs/src/testers/browser-controls/eval_js.py @@ -1,8 +1,13 @@ -# @sniptest filename=goto_new_tab.py +# @sniptest filename=eval_js.py +import json + from notte_sdk import NotteClient client = NotteClient() with client.Session() as session: session.execute(type="goto", url="https://notte.cc/") - session.execute(type="evaluate_js", code="document.title") + # evaluate_js returns the evaluated string; failures raise with the JS error + title = session.evaluate_js("document.title") + # objects and arrays come back as JSON + links = json.loads(session.evaluate_js("Array.from(document.querySelectorAll('a')).map(a => a.href)")) diff --git a/packages/notte-browser/src/notte_browser/session.py b/packages/notte-browser/src/notte_browser/session.py index 9407e18dd..c6f86afce 100644 --- a/packages/notte-browser/src/notte_browser/session.py +++ b/packages/notte-browser/src/notte_browser/session.py @@ -64,7 +64,7 @@ from notte_core.common.telemetry import track_usage from notte_core.credentials.base import BaseVault, LocatorAttributes from notte_core.data.space import DataSpace, ImageData, StructuredData, TBaseModel -from notte_core.errors.actions import ActionExecutionError, InvalidActionError +from notte_core.errors.actions import ActionExecutionError, EvaluateJsNoDataError, InvalidActionError from notte_core.errors.base import NotteBaseError from notte_core.errors.provider import RateLimitError from notte_core.profiling import profiler @@ -1047,6 +1047,44 @@ def execute( self.aexecute(action=action, raise_on_failure=raise_on_failure, **kwargs) # pyright: ignore [reportArgumentType] ) + # raise_on_failure=True (default) -> the evaluated string; failures raise + @overload + async def aevaluate_js(self, code: str, *, raise_on_failure: Literal[True] = ...) -> str: ... + + # raise_on_failure=False -> the ExecutionResult envelope, like execute() + @overload + async def aevaluate_js(self, code: str, *, raise_on_failure: Literal[False]) -> ExecutionResult: ... + + async def aevaluate_js(self, code: str, *, raise_on_failure: bool = True) -> str | ExecutionResult: + """ + Evaluate JavaScript on the current page and return its result as a string. + + The result is stringified the way `evaluate_js` records it: objects and + arrays as JSON, a JS `null` as the string `"null"`. On failure the typed + exception is raised with the actual JavaScript error; pass + `raise_on_failure=False` to get the `ExecutionResult` envelope instead. + """ + result = await self.aexecute(type="evaluate_js", code=code, raise_on_failure=raise_on_failure) + if not raise_on_failure: + return result + if result.data is None: + # cannot happen with this package's execute path, which always sets + # data on a successful eval; a typed error beats a stripped assert + raise EvaluateJsNoDataError() + return result.data.markdown + + @overload + def evaluate_js(self, code: str, *, raise_on_failure: Literal[True] = ...) -> str: ... + + @overload + def evaluate_js(self, code: str, *, raise_on_failure: Literal[False]) -> ExecutionResult: ... + + def evaluate_js(self, code: str, *, raise_on_failure: bool = True) -> str | ExecutionResult: + """ + Synchronous version of aevaluate_js. + """ + return asyncio.run(self.aevaluate_js(code, raise_on_failure=raise_on_failure)) + @overload async def ascrape(self, /, *, only_images: Literal[True], raise_on_failure: bool = True) -> list[ImageData]: ... diff --git a/packages/notte-core/src/notte_core/actions/actions.py b/packages/notte-core/src/notte_core/actions/actions.py index e5c45472e..3148c1e1a 100644 --- a/packages/notte-core/src/notte_core/actions/actions.py +++ b/packages/notte-core/src/notte_core/actions/actions.py @@ -974,10 +974,16 @@ class EvaluateJsAction(ToolAction): **Example:** ```python - session.execute(type="evaluate_js", code="document.title") - session.execute(type="evaluate_js", code="Array.from(document.querySelectorAll('a')).map(a => a.href)") - session.execute(type="evaluate_js", code="(() => { const els = document.querySelectorAll('a'); return els.length; })()") + title = session.evaluate_js("document.title") + links = session.evaluate_js("Array.from(document.querySelectorAll('a')).map(a => a.href)") + count = session.evaluate_js("(() => { const els = document.querySelectorAll('a'); return els.length; })()") ``` + + `session.evaluate_js(code)` returns the evaluated string directly (objects and + arrays as JSON, a JS `null` as the string `"null"`) and raises on failure with + the actual JavaScript error. The action form + `session.execute(type="evaluate_js", code=...)` returns the `ExecutionResult` + envelope instead. """ type: Literal["evaluate_js"] = "evaluate_js" # pyright: ignore [reportIncompatibleVariableOverride] diff --git a/packages/notte-core/src/notte_core/errors/actions.py b/packages/notte-core/src/notte_core/errors/actions.py index e8a1d2838..60d7f81ad 100644 --- a/packages/notte-core/src/notte_core/errors/actions.py +++ b/packages/notte-core/src/notte_core/errors/actions.py @@ -21,6 +21,17 @@ def __init__(self, action_id: str, url: str, reason: str | None = None) -> None: ) +class EvaluateJsNoDataError(ActionError): + def __init__(self) -> None: + message = "evaluate_js reported success but returned no data" + super().__init__( + dev_message=message, + user_message=f"{message}.", + agent_message=message, + should_notify_team=True, + ) + + class NotEnoughActionsListedError(ActionError): def __init__(self, n_trials: int, n_actions: int, threshold: float) -> None: super().__init__( diff --git a/packages/notte-sdk/src/notte_sdk/endpoints/sessions.py b/packages/notte-sdk/src/notte_sdk/endpoints/sessions.py index efe3c9536..8a61e0823 100644 --- a/packages/notte-sdk/src/notte_sdk/endpoints/sessions.py +++ b/packages/notte-sdk/src/notte_sdk/endpoints/sessions.py @@ -44,6 +44,7 @@ from notte_core.common.resource import SyncResource from notte_core.common.telemetry import track_usage from notte_core.data.space import ImageData, StructuredData, TBaseModel +from notte_core.errors.actions import EvaluateJsNoDataError from notte_core.errors.base import NotteBaseError from notte_core.utils.files import create_or_append_cookies_to_file from pydantic import BaseModel @@ -1596,3 +1597,32 @@ def execute( agent_message=fallback_message, ) return result + + # raise_on_failure=True (default) -> the evaluated string; failures raise + @overload + def evaluate_js(self, code: str, *, raise_on_failure: Literal[True] = ...) -> str: ... + + # raise_on_failure=False -> the ExecutionResult envelope, like execute() + @overload + def evaluate_js(self, code: str, *, raise_on_failure: Literal[False]) -> ExecutionResult: ... + + def evaluate_js(self, code: str, *, raise_on_failure: bool = True) -> str | ExecutionResult: + """ + Evaluate JavaScript on the current page and return its result as a string. + + The result is stringified the way `evaluate_js` records it: objects and + arrays as JSON, a JS `null` as the string `"null"`. On failure the typed + exception is raised with the actual JavaScript error; pass + `raise_on_failure=False` to get the `ExecutionResult` envelope instead. + + ```python + payload = json.loads(session.evaluate_js("(async () => JSON.stringify(await res.json()))()")) + ``` + """ + result = self.execute(type="evaluate_js", code=code, raise_on_failure=raise_on_failure) + if not raise_on_failure: + return result + if result.data is None: + # an API build that predates the eval-js fix reports success without data + raise EvaluateJsNoDataError() + return result.data.markdown diff --git a/tests/sdk/test_evaluate_js_helper.py b/tests/sdk/test_evaluate_js_helper.py new file mode 100644 index 000000000..236698ad9 --- /dev/null +++ b/tests/sdk/test_evaluate_js_helper.py @@ -0,0 +1,59 @@ +"""Remote `evaluate_js()`: the string on success, the typed raise on failure.""" + +import datetime as dt + +import pytest +from notte_core.actions import EvaluateJsAction +from notte_core.browser.observation import ExecutionResult +from notte_core.data.space import DataSpace +from notte_core.errors.actions import ActionExecutionError, EvaluateJsNoDataError +from notte_core.errors.base import ErrorConfig + +from tests.sdk.test_execute_raise_on_failure import over_the_wire, remote_session + +CODE = "1 + 1" + + +def eval_result(*, success: bool, markdown: str | None = None, exception: Exception | None = None) -> ExecutionResult: + now = dt.datetime.now(dt.timezone.utc) + return ExecutionResult( + action=EvaluateJsAction(code=CODE), + success=success, + message="ok" if success else "JavaScript evaluation failed: boom", + data=DataSpace(markdown=markdown) if markdown is not None else None, + exception=exception, + started_at=now, + ended_at=now, + ) + + +def test_evaluate_js_returns_the_string() -> None: + session = remote_session(over_the_wire(eval_result(success=True, markdown="2"))) + + assert session.evaluate_js(CODE) == "2" + + +def test_evaluate_js_failure_raises_the_typed_error() -> None: + with ErrorConfig.message_mode("user"): + exception = ActionExecutionError(action_id="evaluate_js", url="https://example.com", reason="boom") + session = remote_session(over_the_wire(eval_result(success=False, exception=exception))) + + with pytest.raises(ActionExecutionError): + _ = session.evaluate_js(CODE) + + +def test_evaluate_js_returns_the_envelope_when_not_raising() -> None: + session = remote_session(over_the_wire(eval_result(success=False))) + + result = session.evaluate_js(CODE, raise_on_failure=False) + + assert isinstance(result, ExecutionResult) + assert result.success is False + + +def test_evaluate_js_success_without_data_raises_instead_of_returning_none() -> None: + """An API build that predates the eval-js fix can report success with no data.""" + session = remote_session(over_the_wire(eval_result(success=True))) + + with pytest.raises(EvaluateJsNoDataError, match="returned no data"): + _ = session.evaluate_js(CODE) diff --git a/tests/test_evaluate_js_helper.py b/tests/test_evaluate_js_helper.py new file mode 100644 index 000000000..f6c03dc94 --- /dev/null +++ b/tests/test_evaluate_js_helper.py @@ -0,0 +1,39 @@ +"""`evaluate_js()` returns the evaluated string; the envelope stays on the `False` overload.""" + +import pytest +from notte_browser.session import NotteSession +from notte_core.browser.observation import ExecutionResult +from notte_core.errors.actions import ActionExecutionError + + +@pytest.mark.asyncio +async def test_aevaluate_js_returns_the_string() -> None: + async with NotteSession(headless=True) as session: + assert await session.aevaluate_js("1 + 1") == "2" + # a JS `null` is a successful evaluation and arrives as the string "null" + assert await session.aevaluate_js("null") == "null" + assert await session.aevaluate_js("[1, 2]") == "[\n 1,\n 2\n]" + + +@pytest.mark.asyncio +async def test_aevaluate_js_failure_raises_the_js_error() -> None: + async with NotteSession(headless=True) as session: + with pytest.raises(ActionExecutionError, match="JavaScript evaluation failed"): + _ = await session.aevaluate_js("notAFunction()") + + +@pytest.mark.asyncio +async def test_aevaluate_js_returns_the_envelope_when_not_raising() -> None: + async with NotteSession(headless=True) as session: + result = await session.aevaluate_js("notAFunction()", raise_on_failure=False) + + assert isinstance(result, ExecutionResult) + assert result.success is False + assert result.message.startswith("JavaScript evaluation failed:") + + +# NOTE: no sync-variant test here on purpose. A sync NotteSession (asyncio.run +# under nest_asyncio) breaks the next async browser launch in the same pytest +# process, so mixing the two in one file flakes under random test ordering. +# The sync wrapper is a one-line delegation to aevaluate_js and its overload +# typing is pinned by typing_cases/evaluate_js_overloads.py. diff --git a/typing_cases/evaluate_js_overloads.py b/typing_cases/evaluate_js_overloads.py new file mode 100644 index 000000000..24b61cb73 --- /dev/null +++ b/typing_cases/evaluate_js_overloads.py @@ -0,0 +1,36 @@ +"""Reveal-type cases for evaluate_js overload resolution (checked by basedpyright and ty). + +Keep this file free of runtime side effects; checkers only need the annotations. +""" + +from __future__ import annotations + +from typing import reveal_type + +from notte_browser.session import NotteSession +from notte_sdk.endpoints.sessions import RemoteSession + + +def _check_remote_session(session: RemoteSession) -> None: + text = session.evaluate_js("1 + 1") + reveal_type(text) # str + envelope = session.evaluate_js("1 + 1", raise_on_failure=False) + reveal_type(envelope) # ExecutionResult + + +def _check_computed_bool(session: RemoteSession, flag: bool) -> None: + # a non-literal bool matches via argument expansion on both checkers; + # no plain-bool overload is needed + either = session.evaluate_js("1 + 1", raise_on_failure=flag) + reveal_type(either) # str | ExecutionResult + + +async def _check_local_session(session: NotteSession) -> None: + text = await session.aevaluate_js("1 + 1") + reveal_type(text) # str + envelope = await session.aevaluate_js("1 + 1", raise_on_failure=False) + reveal_type(envelope) # ExecutionResult + sync_text = session.evaluate_js("1 + 1") + reveal_type(sync_text) # str + sync_envelope = session.evaluate_js("1 + 1", raise_on_failure=False) + reveal_type(sync_envelope) # ExecutionResult