From 50fdb2aac04c3cb154156df918bc58da73e81e85 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Wed, 21 Feb 2024 15:45:36 +0100 Subject: [PATCH] chore: Jupyter tests (#169) * simplify tests for jupyter plugin * Run jupyter tests separately with proper dependencies --- boa/integrations/jupyter/browser.py | 1 + dev-requirements.txt | 4 + tests/__init__.py | 0 tests/unitary/jupyter/conftest.py | 72 ++--------- tests/unitary/jupyter/test_browser.py | 170 +++++++------------------ tests/unitary/jupyter/test_handlers.py | 36 +++--- 6 files changed, 77 insertions(+), 206 deletions(-) create mode 100644 tests/__init__.py diff --git a/boa/integrations/jupyter/browser.py b/boa/integrations/jupyter/browser.py index 37a2bf00..745cad5c 100644 --- a/boa/integrations/jupyter/browser.py +++ b/boa/integrations/jupyter/browser.py @@ -56,6 +56,7 @@ def __init__(self, address=None): Create a BrowserSigner instance. :param address: The account address. If not provided, it will be requested from the browser. """ + address = getattr(address, "address", address) address = _javascript_call( "loadSigner", address, timeout_message=ADDRESS_TIMEOUT_MESSAGE ) diff --git a/dev-requirements.txt b/dev-requirements.txt index f3cf704d..93d94bad 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -12,3 +12,7 @@ pytest pytest-xdist pytest-cov sphinx-rtd-theme + +# jupyter +jupyter_server +nest_asyncio diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unitary/jupyter/conftest.py b/tests/unitary/jupyter/conftest.py index 9d8e709a..e58ddbd5 100644 --- a/tests/unitary/jupyter/conftest.py +++ b/tests/unitary/jupyter/conftest.py @@ -1,74 +1,20 @@ -import sys from multiprocessing.shared_memory import SharedMemory -from unittest.mock import MagicMock import pytest - -@pytest.fixture() -def replace_modules(): - mocked_modules = {} - - def replace(modules: dict): - for module, mock in modules.items(): - assert module not in sys.modules - sys.modules[module] = mock - mocked_modules[module] = mock - - yield replace - for m in mocked_modules: - sys.modules.pop(m) - - -@pytest.fixture() -def tornado_mock(replace_modules): - replace_modules({"tornado.web": MagicMock(authenticated=lambda x: x)}) - - -@pytest.fixture() -def jupyter_module_mock(replace_modules, tornado_mock): - jupyter_mock = MagicMock() - utils = jupyter_mock.utils - serverapp = jupyter_mock.serverapp - base_handlers = jupyter_mock.base.handlers - - utils.url_path_join = lambda *args: "/".join(args) - base_handlers.APIHandler = object - - replace_modules( - { - "jupyter_server.base.handlers": base_handlers, - "jupyter_server.serverapp": serverapp, - "jupyter_server.utils": utils, - } - ) - return jupyter_mock - - -@pytest.fixture() -def nest_asyncio_mock(replace_modules): - mock = MagicMock() - mock.authenticated = lambda x: x - replace_modules({"nest_asyncio": mock}) - return mock +from boa.integrations.jupyter.browser import _generate_token +from boa.integrations.jupyter.constants import SHARED_MEMORY_LENGTH @pytest.fixture() -def shared_memory_length(nest_asyncio_mock): - from boa.integrations.jupyter.constants import SHARED_MEMORY_LENGTH - - return SHARED_MEMORY_LENGTH - - -@pytest.fixture() -def token(nest_asyncio_mock, jupyter_module_mock): - from boa.integrations.jupyter.browser import _generate_token - +def token(): return _generate_token() @pytest.fixture() -def shared_memory(token, shared_memory_length): - memory = SharedMemory(name=token, create=True, size=shared_memory_length) - yield memory - memory.unlink() +def shared_memory(token): + memory = SharedMemory(name=token, create=True, size=SHARED_MEMORY_LENGTH) + try: + yield memory + finally: + memory.unlink() diff --git a/tests/unitary/jupyter/test_browser.py b/tests/unitary/jupyter/test_browser.py index 259631bd..db790276 100644 --- a/tests/unitary/jupyter/test_browser.py +++ b/tests/unitary/jupyter/test_browser.py @@ -1,3 +1,4 @@ +import asyncio import json import re from asyncio import get_event_loop @@ -9,12 +10,8 @@ from eth_account import Account import boa - - -@pytest.fixture() -def display_mock(replace_modules): - with mock.patch("boa.integrations.jupyter.browser.display") as display_mock: - yield display_mock +from boa.integrations.jupyter import BrowserSigner +from boa.rpc import RPCError @pytest.fixture() @@ -27,15 +24,8 @@ def mocked_token(token): @pytest.fixture() -def mock_inject_javascript(): - with mock.patch( - "boa.integrations.jupyter.browser.install_jupyter_javascript_triggers" - ) as inject_mock: - yield inject_mock - - -@pytest.fixture() -def env(browser, account, mock_fork): +def env(account, mock_fork, mock_callback): + mock_callback("loadSigner", account.address) boa.set_browser_env(account) return boa.env @@ -54,6 +44,7 @@ def find_response(mock_calls, func_to_body_dict): The keys represent either an RPC function or a JS function. :return: The response to the last call to the display function. """ + assert mock_calls (javascript,) = [call for call in mock_calls if call.args][-1].args js_func, js_args = re.match( r"window._titanoboa.([a-zA-Z0-9_]+)\(([^)]*)\)", javascript.data @@ -76,7 +67,7 @@ def find_response(mock_calls, func_to_body_dict): @pytest.fixture() -def mock_callback(mocked_token, browser, display_mock, mock_inject_javascript): +def mock_callback(mocked_token, display_mock): """Returns a function that allows mocking the result of the frontend callback.""" with mock.patch( @@ -104,7 +95,8 @@ def create_task(future): memory = SharedMemory(name=mocked_token) memory.buf[0 : len(body)] = body task = MagicMock() - task.result.return_value = get_event_loop().run_until_complete(future) + loop = get_event_loop() + task.result.return_value = loop.run_until_complete(future) return task io_loop.create_task = create_task @@ -114,20 +106,16 @@ def create_task(future): @pytest.fixture() def mock_fork(mock_callback): - mock_callback("evm_snapshot", "0x123") - mock_callback("evm_revert", "0x123") + mock_callback("evm_snapshot", "0x123456") + mock_callback("evm_revert", "0x12345678") data = {"number": "0x123", "timestamp": "0x65bbb460"} mock_callback("eth_getBlockByNumber", data) - yield - boa.reset_env() @pytest.fixture() -def browser(nest_asyncio_mock, jupyter_module_mock): - # Import the browser module after the mocks have been set up - from boa.integrations.jupyter import browser - - return browser +def display_mock(): + with patch("boa.integrations.jupyter.browser.display") as display_mock: + yield display_mock @pytest.fixture() @@ -136,151 +124,89 @@ 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(nest_asyncio_mock, browser): - nest_asyncio_mock.apply.assert_called_once() - +def test_nest_applied(): + assert hasattr(asyncio, "_nest_patched") -def test_browser_signer_given_address(browser, display_mock, mock_inject_javascript): - signer = browser.BrowserSigner("0x1234") - assert signer.address == "0x1234" - display_mock.assert_not_called() - mock_inject_javascript.assert_not_called() - -def test_browser_sign_typed_data( - browser, display_mock, mock_inject_javascript, mock_callback -): - signer = browser.BrowserSigner(boa.env.generate_address()) - signature = boa.env.generate_address() +def test_browser_sign_typed_data(display_mock, mock_callback, env): + signature = env.generate_address() mock_callback("signTypedData", signature) - data = signer.sign_typed_data({"name": "My App"}, {"types": []}, {"data": "0x1234"}) + data = env.signer.sign_typed_data( + {"name": "My App"}, {"types": []}, {"data": "0x1234"} + ) assert data == signature -def test_browser_signer_no_address( - mocked_token, browser, display_mock, mock_callback, mock_inject_javascript -): - mock_callback("loadSigner", "0x123") - signer = browser.BrowserSigner() - assert signer.address == "0x123" - display_mock.assert_called_once() - (js,), _ = display_mock.call_args - assert f'loadSigner("{mocked_token}")' in js.data - mock_inject_javascript.assert_called() - - -def test_browser_signer_colab( - colab_eval_mock, mocked_token, browser, mock_inject_javascript, display_mock -): - colab_eval_mock.return_value = json.dumps({"data": "0x123"}) - signer = browser.BrowserSigner() - assert signer.address == "0x123" +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 = BrowserSigner() + assert signer.address == address colab_eval_mock.assert_called_once() (js,), _ = colab_eval_mock.call_args - assert f'loadSigner("{mocked_token}")' in js - mock_inject_javascript.assert_called() - display_mock.assert_not_called() - - -def test_set_browser_env( - browser, - display_mock, - mock_inject_javascript, - account, - mock_callback, - mock_fork, - env, -): - assert env.eoa == account - assert display_mock.call_count == 4 - assert mock_inject_javascript.call_count == 4 - - -def test_browser_loads_signer( - token, - browser, - display_mock, - mock_callback, - mock_inject_javascript, - account, - mock_fork, -): + assert f'loadSigner("{mocked_token}", null)' in js + + +def test_browser_loads_signer(token, display_mock, mock_callback, account, mock_fork): mock_callback("loadSigner", account.address) boa.set_browser_env() assert boa.env.eoa == account.address - assert ( - type(boa.env._accounts[boa.env.eoa]).__name__ == browser.BrowserSigner.__name__ - ) - mock_inject_javascript.assert_called() + assert type(boa.env._accounts[boa.env.eoa]).__name__ == BrowserSigner.__name__ def test_browser_chain_id(token, env, display_mock, mock_callback): - mock_callback("eth_chainId", "0x123") - assert env.get_chain_id() == "0x123" + display_mock.reset_mock() + mock_callback("eth_chainId", "0x1234") + assert env.get_chain_id() == 4660 mock_callback("wallet_switchEthereumChain") - env.set_chain_id("0x456") - assert display_mock.call_count == 7 + env.set_chain_id(1) + assert display_mock.call_count == 3 (js,), _ = display_mock.call_args_list[-2] assert ( - f'rpc("{token}", "wallet_switchEthereumChain", [{{"chainId": "0x456"}}])' + f'rpc("{token}", "wallet_switchEthereumChain", [{{"chainId": "0x1"}}])' in js.data ) -def test_browser_rpc( - token, - browser, - display_mock, - mock_callback, - mock_inject_javascript, - 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 - assert display_mock.call_count == 5 + assert display_mock.call_count == 6 (js,), _ = display_mock.call_args assert f'rpc("{token}", "eth_gasPrice", [])' in js.data - mock_inject_javascript.assert_called() -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("loadSigner", 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/unitary/jupyter/test_handlers.py index 75db0716..d5064cad 100644 --- a/tests/unitary/jupyter/test_handlers.py +++ b/tests/unitary/jupyter/test_handlers.py @@ -2,6 +2,9 @@ import pytest +from boa.integrations.jupyter.constants import SHARED_MEMORY_LENGTH +from boa.integrations.jupyter.handlers import CallbackHandler, setup_handlers + @pytest.fixture() def server_app_mock(): @@ -11,24 +14,16 @@ def server_app_mock(): @pytest.fixture() -def handlers(jupyter_module_mock, nest_asyncio_mock): - 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, nest_asyncio_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,17 +31,16 @@ def test_setup_handlers(handlers, server_app_mock, nest_asyncio_mock): "host_handlers": [ ( "/base_url/titanoboa_jupyterlab/callback/(titanoboa_jupyterlab_[0-9a-fA-F]{64})$", - handlers.CallbackHandler, + CallbackHandler, ) ], } - nest_asyncio_mock.apply.assert_not_called() 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"} ) @@ -54,16 +48,16 @@ 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} ) -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 +def test_value_error(callback_handler, token, shared_memory): + 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"} ) @@ -72,5 +66,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()