diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index be905d1b..cc37504b 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -45,6 +45,27 @@ jobs: --cov-fail-under=70 tests/unitary/ + jupyter: + name: "integration tests (jupyter)" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install Requirements + run: | + pip install -r dev-requirements.txt + pip install .[jupyter-testing] + + - name: Run Jupyter tests + # run separately to clarify its dependency on dependencies + run: pytest -n auto tests/integration/jupyter/ + anvil: name: "integration tests (anvil)" runs-on: ubuntu-latest diff --git a/pyproject.toml b/pyproject.toml index f09f7cd0..2610f34e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ [project.optional-dependencies] forking-recommended = ["ujson"] +jupyter-testing = ["jupyter_server", "ipython", "nest_asyncio"] [build-system] requires = ["setuptools", "wheel"] diff --git a/tests/unitary/jupyter/IPython/__init__.py b/tests/__init__.py similarity index 100% rename from tests/unitary/jupyter/IPython/__init__.py rename to tests/__init__.py diff --git a/tests/unitary/jupyter/conftest.py b/tests/integration/jupyter/conftest.py similarity index 100% rename from tests/unitary/jupyter/conftest.py rename to tests/integration/jupyter/conftest.py diff --git a/tests/unitary/jupyter/test_browser.py b/tests/integration/jupyter/test_browser.py similarity index 78% rename from tests/unitary/jupyter/test_browser.py rename to tests/integration/jupyter/test_browser.py index cc9d2072..c3b16b2f 100644 --- a/tests/unitary/jupyter/test_browser.py +++ b/tests/integration/jupyter/test_browser.py @@ -1,3 +1,4 @@ +import asyncio import json import re from asyncio import get_event_loop @@ -9,6 +10,8 @@ from eth_account import Account import boa +from boa.integrations.jupyter import BrowserRPC, BrowserSigner +from boa.rpc import RPCError @pytest.fixture() @@ -21,7 +24,7 @@ def mocked_token(token): @pytest.fixture() -def env(browser, account, mock_fork, mock_callback): +def env(account, mock_fork, mock_callback): mock_callback("eth_requestAccounts", [account.address]) boa.set_browser_env(account) return boa.env @@ -64,7 +67,7 @@ def find_response(mock_calls, func_to_body_dict): @pytest.fixture() -def mock_callback(mocked_token, browser, display_mock): +def mock_callback(mocked_token, display_mock): """Returns a function that allows mocking the result of the frontend callback.""" with mock.patch("boa.integrations.jupyter.browser.get_event_loop") as mock_get_loop: @@ -110,17 +113,9 @@ def mock_fork(mock_callback): @pytest.fixture() -def browser(): - # Import the browser module after the mocks have been set up - from boa.integrations.jupyter import browser - - return browser - - -@pytest.fixture() -def display_mock(browser): - yield browser.display - browser.display.reset_mock() +def display_mock(): + with patch("boa.integrations.jupyter.browser.display") as display_mock: + yield display_mock @pytest.fixture() @@ -129,17 +124,16 @@ def account(): @pytest.fixture() -def colab_eval_mock(browser): - colab_eval_mock = MagicMock() - with patch.object(browser, "colab_eval_js", colab_eval_mock): +def colab_eval_mock(): + with patch("boa.integrations.jupyter.browser.colab_eval_js") as colab_eval_mock: yield colab_eval_mock -def test_nest_applied(browser): - browser.nest_asyncio.apply.assert_called() +def test_nest_applied(): + assert hasattr(asyncio, "_nest_patched") -def test_browser_sign_typed_data(browser, display_mock, mock_callback, env): +def test_browser_sign_typed_data(display_mock, mock_callback, env): signature = env.generate_address() mock_callback("eth_signTypedData_v4", signature) data = env.signer.sign_typed_data( @@ -148,16 +142,16 @@ def test_browser_sign_typed_data(browser, display_mock, mock_callback, env): assert data == signature -def test_browser_rpc_inject_js(mocked_token, display_mock, browser, mock_callback): - browser.BrowserRPC() +def test_browser_rpc_inject_js(mocked_token, display_mock, mock_callback): + BrowserRPC() (((js1,), _),) = display_mock.call_args_list assert "window._titanoboa = " in js1.data -def test_browser_signer_colab(colab_eval_mock, mocked_token, browser, display_mock): +def test_browser_signer_colab(colab_eval_mock, mocked_token, display_mock): address = boa.env.generate_address() colab_eval_mock.return_value = json.dumps({"data": [address]}) - signer = browser.BrowserSigner() + signer = BrowserSigner() assert signer.address == address colab_eval_mock.assert_called_once() (js,), _ = colab_eval_mock.call_args @@ -165,15 +159,11 @@ def test_browser_signer_colab(colab_eval_mock, mocked_token, browser, display_mo display_mock.assert_called_once() -def test_browser_loads_signer( - token, browser, display_mock, mock_callback, account, mock_fork -): +def test_browser_loads_signer(token, display_mock, mock_callback, account, mock_fork): mock_callback("eth_requestAccounts", [account.address]) boa.set_browser_env() assert boa.env.eoa == account.address - assert ( - type(boa.env._accounts[boa.env.eoa]).__name__ == browser.BrowserSigner.__name__ - ) + assert type(boa.env._accounts[boa.env.eoa]).__name__ == BrowserSigner.__name__ def test_browser_chain_id(token, env, display_mock, mock_callback): @@ -190,9 +180,7 @@ def test_browser_chain_id(token, env, display_mock, mock_callback): ) -def test_browser_rpc( - token, browser, display_mock, mock_callback, account, mock_fork, env -): +def test_browser_rpc(token, display_mock, mock_callback, account, mock_fork, env): mock_callback("eth_gasPrice", "0x123") assert env.get_gas_price() == 291 @@ -201,37 +189,33 @@ def test_browser_rpc( assert f'rpc("{token}", "eth_gasPrice", [])' in js.data -def test_browser_rpc_error( - token, browser, display_mock, mock_callback, account, mock_fork, env -): +def test_browser_rpc_error(token, display_mock, mock_callback, account, mock_fork, env): rpc_error = {"code": -32000, "message": "Reverted"} mock_callback( "eth_gasPrice", error={"message": "error", "info": {"error": rpc_error}} ) - with pytest.raises(browser.RPCError) as exc_info: + with pytest.raises(RPCError) as exc_info: env.get_gas_price() assert str(exc_info.value) == "-32000: Reverted" def test_browser_rpc_server_error( - token, browser, display_mock, mock_callback, account, mock_fork, env + token, display_mock, mock_callback, account, mock_fork, env ): error = { "code": "UNKNOWN_ERROR", "error": {"code": -32603, "message": "server error"}, } mock_callback("eth_gasPrice", error=error) - with pytest.raises(browser.RPCError) as exc_info: + with pytest.raises(RPCError) as exc_info: env.get_gas_price() assert str(exc_info.value) == "-32603: server error" -def test_browser_js_error( - token, browser, display_mock, mock_callback, account, mock_fork -): +def test_browser_js_error(token, display_mock, mock_callback, account, mock_fork): mock_callback( "eth_requestAccounts", error={"message": "custom message", "stack": ""} ) - with pytest.raises(browser.RPCError) as exc_info: - browser.BrowserSigner() + with pytest.raises(RPCError) as exc_info: + BrowserSigner() assert str(exc_info.value) == "CALLBACK_ERROR: custom message" diff --git a/tests/unitary/jupyter/test_handlers.py b/tests/integration/jupyter/test_handlers.py similarity index 72% rename from tests/unitary/jupyter/test_handlers.py rename to tests/integration/jupyter/test_handlers.py index a50f3c25..11000506 100644 --- a/tests/unitary/jupyter/test_handlers.py +++ b/tests/integration/jupyter/test_handlers.py @@ -2,6 +2,8 @@ import pytest +from boa.integrations.jupyter.handlers import CallbackHandler, setup_handlers + @pytest.fixture() def server_app_mock(): @@ -11,24 +13,16 @@ def server_app_mock(): @pytest.fixture() -def handlers(): - from boa.integrations.jupyter import handlers - - return handlers - - -@pytest.fixture() -def callback_handler(handlers): - handler = handlers.CallbackHandler() - handler.request = MagicMock() +def callback_handler(server_app_mock): + request_mock = MagicMock() + handler = CallbackHandler(server_app_mock.web_app, request_mock) handler.current_user = MagicMock() - handler.set_status = MagicMock() handler.finish = MagicMock() return handler -def test_setup_handlers(handlers, server_app_mock): - handlers.setup_handlers(server_app_mock) +def test_setup_handlers(server_app_mock): + setup_handlers(server_app_mock) server_app_mock.web_app.add_handlers.assert_called_once() _, kwargs = server_app_mock.web_app.add_handlers.call_args assert kwargs == { @@ -36,7 +30,7 @@ def test_setup_handlers(handlers, server_app_mock): "host_handlers": [ ( "/base_url/titanoboa_jupyterlab/callback/(titanoboa_jupyterlab_[0-9a-fA-F]{64})$", - handlers.CallbackHandler, + CallbackHandler, ) ], } @@ -45,7 +39,7 @@ def test_setup_handlers(handlers, server_app_mock): def test_no_body(callback_handler, token): callback_handler.request.body = None callback_handler.post(token) - callback_handler.set_status.assert_called_once_with(400) + assert callback_handler.get_status() == 400 callback_handler.finish.assert_called_once_with( {"error": "Request body is required"} ) @@ -53,7 +47,7 @@ def test_no_body(callback_handler, token): def test_invalid_token(callback_handler, token): callback_handler.post(token) - callback_handler.set_status.assert_called_once_with(404) + assert callback_handler.get_status() == 404 callback_handler.finish.assert_called_once_with( {"error": "Invalid token: " + token} ) @@ -62,7 +56,7 @@ def test_invalid_token(callback_handler, token): def test_value_error(callback_handler, token, shared_memory, shared_memory_length): callback_handler.request.body = b"0" * shared_memory_length # no space for the \0 callback_handler.post(token) - callback_handler.set_status.assert_called_once_with(413) + assert callback_handler.get_status() == 413 callback_handler.finish.assert_called_once_with( {"error": "Request body has 51201 bytes, but only 51200 are allowed"} ) @@ -71,5 +65,5 @@ def test_value_error(callback_handler, token, shared_memory, shared_memory_lengt def test_success(callback_handler, token, shared_memory): callback_handler.request.body = b"body" callback_handler.post(token) - callback_handler.set_status.assert_called_once_with(204) + assert callback_handler.get_status() == 204 callback_handler.finish.assert_called_once_with() diff --git a/tests/unitary/jupyter/IPython/display.py b/tests/unitary/jupyter/IPython/display.py deleted file mode 100644 index 902a8a74..00000000 --- a/tests/unitary/jupyter/IPython/display.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -Fake IPython.display module for testing without Jupyter server installed. -""" -from dataclasses import dataclass -from unittest.mock import MagicMock - -display = MagicMock() - - -@dataclass -class Javascript: - data: str diff --git a/tests/unitary/jupyter/jupyter_server/__init__.py b/tests/unitary/jupyter/jupyter_server/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unitary/jupyter/jupyter_server/base/__init__.py b/tests/unitary/jupyter/jupyter_server/base/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unitary/jupyter/jupyter_server/base/handlers.py b/tests/unitary/jupyter/jupyter_server/base/handlers.py deleted file mode 100644 index e06f4ca3..00000000 --- a/tests/unitary/jupyter/jupyter_server/base/handlers.py +++ /dev/null @@ -1,2 +0,0 @@ -class APIHandler: - ... diff --git a/tests/unitary/jupyter/jupyter_server/serverapp.py b/tests/unitary/jupyter/jupyter_server/serverapp.py deleted file mode 100644 index 7d4064e6..00000000 --- a/tests/unitary/jupyter/jupyter_server/serverapp.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Fake server app for testing without Jupyter server installed. -""" - - -class ServerApp: - pass diff --git a/tests/unitary/jupyter/jupyter_server/utils.py b/tests/unitary/jupyter/jupyter_server/utils.py deleted file mode 100644 index 76e2dfda..00000000 --- a/tests/unitary/jupyter/jupyter_server/utils.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Fake jupyter_server.utils module for testing without Jupyter server. -""" - - -def url_path_join(*args): - return "/".join(args) diff --git a/tests/unitary/jupyter/nest_asyncio/__init__.py b/tests/unitary/jupyter/nest_asyncio/__init__.py deleted file mode 100644 index 779f4537..00000000 --- a/tests/unitary/jupyter/nest_asyncio/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -Fake nest_asyncio module for testing without Jupyter server installed. -""" -from unittest.mock import MagicMock - -apply = MagicMock() diff --git a/tests/unitary/jupyter/tornado/__init__.py b/tests/unitary/jupyter/tornado/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unitary/jupyter/tornado/web.py b/tests/unitary/jupyter/tornado/web.py deleted file mode 100644 index 9313699d..00000000 --- a/tests/unitary/jupyter/tornado/web.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Fake tornado.web module for testing without Jupyter server installed. -""" - - -def authenticated(x): - return x