hud-python 0.4.45__py3-none-any.whl → 0.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hud/__init__.py +27 -7
- hud/agents/__init__.py +11 -5
- hud/agents/base.py +220 -500
- hud/agents/claude.py +200 -240
- hud/agents/gemini.py +275 -0
- hud/agents/gemini_cua.py +335 -0
- hud/agents/grounded_openai.py +98 -100
- hud/agents/misc/integration_test_agent.py +51 -20
- hud/agents/misc/response_agent.py +41 -36
- hud/agents/openai.py +291 -292
- hud/agents/{openai_chat_generic.py → openai_chat.py} +80 -34
- hud/agents/operator.py +211 -0
- hud/agents/tests/conftest.py +133 -0
- hud/agents/tests/test_base.py +300 -622
- hud/agents/tests/test_base_runtime.py +233 -0
- hud/agents/tests/test_claude.py +379 -210
- hud/agents/tests/test_client.py +9 -10
- hud/agents/tests/test_gemini.py +369 -0
- hud/agents/tests/test_grounded_openai_agent.py +65 -50
- hud/agents/tests/test_openai.py +376 -140
- hud/agents/tests/test_operator.py +362 -0
- hud/agents/tests/test_run_eval.py +179 -0
- hud/cli/__init__.py +461 -545
- hud/cli/analyze.py +43 -5
- hud/cli/build.py +664 -110
- hud/cli/debug.py +8 -5
- hud/cli/dev.py +882 -734
- hud/cli/eval.py +782 -668
- hud/cli/flows/dev.py +167 -0
- hud/cli/flows/init.py +191 -0
- hud/cli/flows/tasks.py +153 -56
- hud/cli/flows/templates.py +151 -0
- hud/cli/flows/tests/__init__.py +1 -0
- hud/cli/flows/tests/test_dev.py +126 -0
- hud/cli/init.py +60 -58
- hud/cli/push.py +29 -11
- hud/cli/rft.py +311 -0
- hud/cli/rft_status.py +145 -0
- hud/cli/tests/test_analyze.py +5 -5
- hud/cli/tests/test_analyze_metadata.py +3 -2
- hud/cli/tests/test_analyze_module.py +120 -0
- hud/cli/tests/test_build.py +108 -6
- hud/cli/tests/test_build_failure.py +41 -0
- hud/cli/tests/test_build_module.py +50 -0
- hud/cli/tests/test_cli_init.py +6 -1
- hud/cli/tests/test_cli_more_wrappers.py +30 -0
- hud/cli/tests/test_cli_root.py +140 -0
- hud/cli/tests/test_convert.py +361 -0
- hud/cli/tests/test_debug.py +12 -10
- hud/cli/tests/test_dev.py +197 -0
- hud/cli/tests/test_eval.py +251 -0
- hud/cli/tests/test_eval_bedrock.py +51 -0
- hud/cli/tests/test_init.py +124 -0
- hud/cli/tests/test_main_module.py +11 -5
- hud/cli/tests/test_mcp_server.py +12 -100
- hud/cli/tests/test_push_happy.py +74 -0
- hud/cli/tests/test_push_wrapper.py +23 -0
- hud/cli/tests/test_registry.py +1 -1
- hud/cli/tests/test_utils.py +1 -1
- hud/cli/{rl → utils}/celebrate.py +14 -12
- hud/cli/utils/config.py +18 -1
- hud/cli/utils/docker.py +130 -4
- hud/cli/utils/env_check.py +9 -9
- hud/cli/utils/git.py +136 -0
- hud/cli/utils/interactive.py +39 -5
- hud/cli/utils/metadata.py +69 -0
- hud/cli/utils/runner.py +1 -1
- hud/cli/utils/server.py +2 -2
- hud/cli/utils/source_hash.py +3 -3
- hud/cli/utils/tasks.py +4 -1
- hud/cli/utils/tests/__init__.py +0 -0
- hud/cli/utils/tests/test_config.py +58 -0
- hud/cli/utils/tests/test_docker.py +93 -0
- hud/cli/utils/tests/test_docker_hints.py +71 -0
- hud/cli/utils/tests/test_env_check.py +74 -0
- hud/cli/utils/tests/test_environment.py +42 -0
- hud/cli/utils/tests/test_git.py +142 -0
- hud/cli/utils/tests/test_interactive_module.py +60 -0
- hud/cli/utils/tests/test_local_runner.py +50 -0
- hud/cli/utils/tests/test_logging_utils.py +23 -0
- hud/cli/utils/tests/test_metadata.py +49 -0
- hud/cli/utils/tests/test_package_runner.py +35 -0
- hud/cli/utils/tests/test_registry_utils.py +49 -0
- hud/cli/utils/tests/test_remote_runner.py +25 -0
- hud/cli/utils/tests/test_runner_modules.py +52 -0
- hud/cli/utils/tests/test_source_hash.py +36 -0
- hud/cli/utils/tests/test_tasks.py +80 -0
- hud/cli/utils/version_check.py +258 -0
- hud/cli/{rl → utils}/viewer.py +2 -2
- hud/clients/README.md +12 -11
- hud/clients/__init__.py +4 -3
- hud/clients/base.py +166 -26
- hud/clients/environment.py +51 -0
- hud/clients/fastmcp.py +13 -6
- hud/clients/mcp_use.py +40 -15
- hud/clients/tests/test_analyze_scenarios.py +206 -0
- hud/clients/tests/test_protocol.py +9 -3
- hud/datasets/__init__.py +23 -20
- hud/datasets/loader.py +327 -0
- hud/datasets/runner.py +192 -105
- hud/datasets/tests/__init__.py +0 -0
- hud/datasets/tests/test_loader.py +221 -0
- hud/datasets/tests/test_utils.py +315 -0
- hud/datasets/utils.py +270 -90
- hud/environment/__init__.py +50 -0
- hud/environment/connection.py +206 -0
- hud/environment/connectors/__init__.py +33 -0
- hud/environment/connectors/base.py +68 -0
- hud/environment/connectors/local.py +177 -0
- hud/environment/connectors/mcp_config.py +109 -0
- hud/environment/connectors/openai.py +101 -0
- hud/environment/connectors/remote.py +172 -0
- hud/environment/environment.py +694 -0
- hud/environment/integrations/__init__.py +45 -0
- hud/environment/integrations/adk.py +67 -0
- hud/environment/integrations/anthropic.py +196 -0
- hud/environment/integrations/gemini.py +92 -0
- hud/environment/integrations/langchain.py +82 -0
- hud/environment/integrations/llamaindex.py +68 -0
- hud/environment/integrations/openai.py +238 -0
- hud/environment/mock.py +306 -0
- hud/environment/router.py +112 -0
- hud/environment/scenarios.py +493 -0
- hud/environment/tests/__init__.py +1 -0
- hud/environment/tests/test_connection.py +317 -0
- hud/environment/tests/test_connectors.py +218 -0
- hud/environment/tests/test_environment.py +161 -0
- hud/environment/tests/test_integrations.py +257 -0
- hud/environment/tests/test_local_connectors.py +201 -0
- hud/environment/tests/test_scenarios.py +280 -0
- hud/environment/tests/test_tools.py +208 -0
- hud/environment/types.py +23 -0
- hud/environment/utils/__init__.py +35 -0
- hud/environment/utils/formats.py +215 -0
- hud/environment/utils/schema.py +171 -0
- hud/environment/utils/tool_wrappers.py +113 -0
- hud/eval/__init__.py +67 -0
- hud/eval/context.py +674 -0
- hud/eval/display.py +299 -0
- hud/eval/instrument.py +185 -0
- hud/eval/manager.py +466 -0
- hud/eval/parallel.py +268 -0
- hud/eval/task.py +340 -0
- hud/eval/tests/__init__.py +1 -0
- hud/eval/tests/test_context.py +178 -0
- hud/eval/tests/test_eval.py +210 -0
- hud/eval/tests/test_manager.py +152 -0
- hud/eval/tests/test_parallel.py +168 -0
- hud/eval/tests/test_task.py +145 -0
- hud/eval/types.py +63 -0
- hud/eval/utils.py +183 -0
- hud/patches/__init__.py +19 -0
- hud/patches/mcp_patches.py +151 -0
- hud/patches/warnings.py +54 -0
- hud/samples/browser.py +4 -4
- hud/server/__init__.py +2 -1
- hud/server/low_level.py +2 -1
- hud/server/router.py +164 -0
- hud/server/server.py +567 -80
- hud/server/tests/test_mcp_server_integration.py +11 -11
- hud/server/tests/test_mcp_server_more.py +1 -1
- hud/server/tests/test_server_extra.py +2 -0
- hud/settings.py +45 -3
- hud/shared/exceptions.py +36 -10
- hud/shared/hints.py +26 -1
- hud/shared/requests.py +15 -3
- hud/shared/tests/test_exceptions.py +40 -31
- hud/shared/tests/test_hints.py +167 -0
- hud/telemetry/__init__.py +20 -19
- hud/telemetry/exporter.py +201 -0
- hud/telemetry/instrument.py +158 -253
- hud/telemetry/tests/test_eval_telemetry.py +356 -0
- hud/telemetry/tests/test_exporter.py +258 -0
- hud/telemetry/tests/test_instrument.py +401 -0
- hud/tools/__init__.py +16 -2
- hud/tools/apply_patch.py +639 -0
- hud/tools/base.py +54 -4
- hud/tools/bash.py +2 -2
- hud/tools/computer/__init__.py +4 -0
- hud/tools/computer/anthropic.py +2 -2
- hud/tools/computer/gemini.py +385 -0
- hud/tools/computer/hud.py +23 -6
- hud/tools/computer/openai.py +20 -21
- hud/tools/computer/qwen.py +434 -0
- hud/tools/computer/settings.py +37 -0
- hud/tools/edit.py +3 -7
- hud/tools/executors/base.py +4 -2
- hud/tools/executors/pyautogui.py +1 -1
- hud/tools/grounding/grounded_tool.py +13 -18
- hud/tools/grounding/grounder.py +10 -31
- hud/tools/grounding/tests/test_grounded_tool.py +26 -44
- hud/tools/jupyter.py +330 -0
- hud/tools/playwright.py +18 -3
- hud/tools/shell.py +308 -0
- hud/tools/tests/test_apply_patch.py +718 -0
- hud/tools/tests/test_computer.py +4 -9
- hud/tools/tests/test_computer_actions.py +24 -2
- hud/tools/tests/test_jupyter_tool.py +181 -0
- hud/tools/tests/test_shell.py +596 -0
- hud/tools/tests/test_submit.py +85 -0
- hud/tools/tests/test_types.py +193 -0
- hud/tools/types.py +21 -1
- hud/types.py +167 -57
- hud/utils/__init__.py +2 -0
- hud/utils/env.py +67 -0
- hud/utils/hud_console.py +61 -3
- hud/utils/mcp.py +15 -58
- hud/utils/strict_schema.py +162 -0
- hud/utils/tests/test_init.py +1 -2
- hud/utils/tests/test_mcp.py +1 -28
- hud/utils/tests/test_pretty_errors.py +186 -0
- hud/utils/tests/test_tool_shorthand.py +154 -0
- hud/utils/tests/test_version.py +1 -1
- hud/utils/types.py +20 -0
- hud/version.py +1 -1
- hud_python-0.5.1.dist-info/METADATA +264 -0
- hud_python-0.5.1.dist-info/RECORD +299 -0
- {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/WHEEL +1 -1
- hud/agents/langchain.py +0 -261
- hud/agents/lite_llm.py +0 -72
- hud/cli/rl/__init__.py +0 -180
- hud/cli/rl/config.py +0 -101
- hud/cli/rl/display.py +0 -133
- hud/cli/rl/gpu.py +0 -63
- hud/cli/rl/gpu_utils.py +0 -321
- hud/cli/rl/local_runner.py +0 -595
- hud/cli/rl/presets.py +0 -96
- hud/cli/rl/remote_runner.py +0 -463
- hud/cli/rl/rl_api.py +0 -150
- hud/cli/rl/vllm.py +0 -177
- hud/cli/rl/wait_utils.py +0 -89
- hud/datasets/parallel.py +0 -687
- hud/misc/__init__.py +0 -1
- hud/misc/claude_plays_pokemon.py +0 -292
- hud/otel/__init__.py +0 -35
- hud/otel/collector.py +0 -142
- hud/otel/config.py +0 -181
- hud/otel/context.py +0 -570
- hud/otel/exporters.py +0 -369
- hud/otel/instrumentation.py +0 -135
- hud/otel/processors.py +0 -121
- hud/otel/tests/__init__.py +0 -1
- hud/otel/tests/test_processors.py +0 -197
- hud/rl/README.md +0 -30
- hud/rl/__init__.py +0 -1
- hud/rl/actor.py +0 -176
- hud/rl/buffer.py +0 -405
- hud/rl/chat_template.jinja +0 -101
- hud/rl/config.py +0 -192
- hud/rl/distributed.py +0 -132
- hud/rl/learner.py +0 -637
- hud/rl/tests/__init__.py +0 -1
- hud/rl/tests/test_learner.py +0 -186
- hud/rl/train.py +0 -382
- hud/rl/types.py +0 -101
- hud/rl/utils/start_vllm_server.sh +0 -30
- hud/rl/utils.py +0 -524
- hud/rl/vllm_adapter.py +0 -143
- hud/telemetry/job.py +0 -352
- hud/telemetry/replay.py +0 -74
- hud/telemetry/tests/test_replay.py +0 -40
- hud/telemetry/tests/test_trace.py +0 -63
- hud/telemetry/trace.py +0 -158
- hud/utils/agent_factories.py +0 -86
- hud/utils/async_utils.py +0 -65
- hud/utils/group_eval.py +0 -223
- hud/utils/progress.py +0 -149
- hud/utils/tasks.py +0 -127
- hud/utils/tests/test_async_utils.py +0 -173
- hud/utils/tests/test_progress.py +0 -261
- hud_python-0.4.45.dist-info/METADATA +0 -552
- hud_python-0.4.45.dist-info/RECORD +0 -228
- {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.45.dist-info → hud_python-0.5.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Tests for hud.environment.connection module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import mcp.types as mcp_types
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from hud.environment.connection import ConnectionConfig, ConnectionType, Connector
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestConnectionConfig:
|
|
14
|
+
"""Tests for ConnectionConfig."""
|
|
15
|
+
|
|
16
|
+
def test_default_config(self) -> None:
|
|
17
|
+
"""Config with no options set."""
|
|
18
|
+
config = ConnectionConfig()
|
|
19
|
+
assert config.prefix is None
|
|
20
|
+
assert config.include is None
|
|
21
|
+
assert config.exclude is None
|
|
22
|
+
assert config.transform is None
|
|
23
|
+
|
|
24
|
+
def test_config_with_options(self) -> None:
|
|
25
|
+
"""Config with all options set."""
|
|
26
|
+
transform_fn = lambda t: t # noqa: E731
|
|
27
|
+
config = ConnectionConfig(
|
|
28
|
+
prefix="test",
|
|
29
|
+
include=["tool1", "tool2"],
|
|
30
|
+
exclude=["tool3"],
|
|
31
|
+
transform=transform_fn,
|
|
32
|
+
)
|
|
33
|
+
assert config.prefix == "test"
|
|
34
|
+
assert config.include == ["tool1", "tool2"]
|
|
35
|
+
assert config.exclude == ["tool3"]
|
|
36
|
+
assert config.transform is transform_fn
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestConnectionType:
|
|
40
|
+
"""Tests for ConnectionType enum."""
|
|
41
|
+
|
|
42
|
+
def test_local_type(self) -> None:
|
|
43
|
+
"""LOCAL type for stdio/Docker connections."""
|
|
44
|
+
assert ConnectionType.LOCAL.value == "local"
|
|
45
|
+
|
|
46
|
+
def test_remote_type(self) -> None:
|
|
47
|
+
"""REMOTE type for HTTP connections."""
|
|
48
|
+
assert ConnectionType.REMOTE.value == "remote"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestConnector:
|
|
52
|
+
"""Tests for Connector class."""
|
|
53
|
+
|
|
54
|
+
def test_init_stores_transport_config(self) -> None:
|
|
55
|
+
"""__init__ stores transport config, doesn't create client."""
|
|
56
|
+
transport = {"server": {"url": "http://example.com"}}
|
|
57
|
+
config = ConnectionConfig()
|
|
58
|
+
|
|
59
|
+
connector = Connector(
|
|
60
|
+
transport=transport,
|
|
61
|
+
config=config,
|
|
62
|
+
name="test",
|
|
63
|
+
connection_type=ConnectionType.REMOTE,
|
|
64
|
+
auth="test-token",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
assert connector._transport == transport
|
|
68
|
+
assert connector._auth == "test-token"
|
|
69
|
+
assert connector.name == "test"
|
|
70
|
+
assert connector.connection_type == ConnectionType.REMOTE
|
|
71
|
+
assert connector.client is None # Not created yet
|
|
72
|
+
assert connector._tools_cache is None
|
|
73
|
+
|
|
74
|
+
def test_is_local_property(self) -> None:
|
|
75
|
+
"""is_local returns True for LOCAL connections."""
|
|
76
|
+
connector = Connector(
|
|
77
|
+
transport={},
|
|
78
|
+
config=ConnectionConfig(),
|
|
79
|
+
name="local-test",
|
|
80
|
+
connection_type=ConnectionType.LOCAL,
|
|
81
|
+
)
|
|
82
|
+
assert connector.is_local is True
|
|
83
|
+
assert connector.is_remote is False
|
|
84
|
+
|
|
85
|
+
def test_is_remote_property(self) -> None:
|
|
86
|
+
"""is_remote returns True for REMOTE connections."""
|
|
87
|
+
connector = Connector(
|
|
88
|
+
transport={},
|
|
89
|
+
config=ConnectionConfig(),
|
|
90
|
+
name="remote-test",
|
|
91
|
+
connection_type=ConnectionType.REMOTE,
|
|
92
|
+
)
|
|
93
|
+
assert connector.is_remote is True
|
|
94
|
+
assert connector.is_local is False
|
|
95
|
+
|
|
96
|
+
def test_is_connected_false_when_no_client(self) -> None:
|
|
97
|
+
"""is_connected returns False when client is None."""
|
|
98
|
+
connector = Connector(
|
|
99
|
+
transport={},
|
|
100
|
+
config=ConnectionConfig(),
|
|
101
|
+
name="test",
|
|
102
|
+
connection_type=ConnectionType.REMOTE,
|
|
103
|
+
)
|
|
104
|
+
assert connector.is_connected is False
|
|
105
|
+
|
|
106
|
+
def test_cached_tools_empty_initially(self) -> None:
|
|
107
|
+
"""cached_tools returns empty list initially."""
|
|
108
|
+
connector = Connector(
|
|
109
|
+
transport={},
|
|
110
|
+
config=ConnectionConfig(),
|
|
111
|
+
name="test",
|
|
112
|
+
connection_type=ConnectionType.REMOTE,
|
|
113
|
+
)
|
|
114
|
+
assert connector.cached_tools == []
|
|
115
|
+
|
|
116
|
+
@pytest.mark.asyncio
|
|
117
|
+
async def test_connect_creates_client(self) -> None:
|
|
118
|
+
"""connect() creates FastMCPClient and enters context."""
|
|
119
|
+
transport = {"server": {"url": "http://example.com"}}
|
|
120
|
+
connector = Connector(
|
|
121
|
+
transport=transport,
|
|
122
|
+
config=ConnectionConfig(),
|
|
123
|
+
name="test",
|
|
124
|
+
connection_type=ConnectionType.REMOTE,
|
|
125
|
+
auth="test-token",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
mock_client = MagicMock()
|
|
129
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
130
|
+
mock_client.is_connected = MagicMock(return_value=True)
|
|
131
|
+
|
|
132
|
+
# Patch where it's imported from, not where it's used
|
|
133
|
+
with patch("fastmcp.client.Client", return_value=mock_client) as mock_cls:
|
|
134
|
+
await connector.connect()
|
|
135
|
+
|
|
136
|
+
# Client was created with correct args
|
|
137
|
+
mock_cls.assert_called_once_with(transport=transport, auth="test-token")
|
|
138
|
+
# Client context was entered
|
|
139
|
+
mock_client.__aenter__.assert_called_once()
|
|
140
|
+
# Client is now set
|
|
141
|
+
assert connector.client is mock_client
|
|
142
|
+
|
|
143
|
+
@pytest.mark.asyncio
|
|
144
|
+
async def test_disconnect_clears_client(self) -> None:
|
|
145
|
+
"""disconnect() exits client context and clears state."""
|
|
146
|
+
connector = Connector(
|
|
147
|
+
transport={},
|
|
148
|
+
config=ConnectionConfig(),
|
|
149
|
+
name="test",
|
|
150
|
+
connection_type=ConnectionType.REMOTE,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
mock_client = MagicMock()
|
|
154
|
+
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
155
|
+
mock_client.is_connected = MagicMock(return_value=True)
|
|
156
|
+
connector.client = mock_client
|
|
157
|
+
connector._tools_cache = [MagicMock()]
|
|
158
|
+
|
|
159
|
+
await connector.disconnect()
|
|
160
|
+
|
|
161
|
+
mock_client.__aexit__.assert_called_once_with(None, None, None)
|
|
162
|
+
assert connector.client is None
|
|
163
|
+
assert connector._tools_cache is None
|
|
164
|
+
|
|
165
|
+
@pytest.mark.asyncio
|
|
166
|
+
async def test_list_tools_raises_when_not_connected(self) -> None:
|
|
167
|
+
"""list_tools() raises RuntimeError when not connected."""
|
|
168
|
+
connector = Connector(
|
|
169
|
+
transport={},
|
|
170
|
+
config=ConnectionConfig(),
|
|
171
|
+
name="test",
|
|
172
|
+
connection_type=ConnectionType.REMOTE,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
with pytest.raises(RuntimeError, match="Not connected"):
|
|
176
|
+
await connector.list_tools()
|
|
177
|
+
|
|
178
|
+
@pytest.mark.asyncio
|
|
179
|
+
async def test_list_tools_applies_include_filter(self) -> None:
|
|
180
|
+
"""list_tools() filters tools based on include list."""
|
|
181
|
+
connector = Connector(
|
|
182
|
+
transport={},
|
|
183
|
+
config=ConnectionConfig(include=["tool1"]),
|
|
184
|
+
name="test",
|
|
185
|
+
connection_type=ConnectionType.REMOTE,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
mock_client = MagicMock()
|
|
189
|
+
mock_client.list_tools = AsyncMock(
|
|
190
|
+
return_value=[
|
|
191
|
+
mcp_types.Tool(name="tool1", description="Tool 1", inputSchema={}),
|
|
192
|
+
mcp_types.Tool(name="tool2", description="Tool 2", inputSchema={}),
|
|
193
|
+
]
|
|
194
|
+
)
|
|
195
|
+
connector.client = mock_client
|
|
196
|
+
|
|
197
|
+
tools = await connector.list_tools()
|
|
198
|
+
|
|
199
|
+
assert len(tools) == 1
|
|
200
|
+
assert tools[0].name == "tool1"
|
|
201
|
+
|
|
202
|
+
@pytest.mark.asyncio
|
|
203
|
+
async def test_list_tools_applies_exclude_filter(self) -> None:
|
|
204
|
+
"""list_tools() filters out tools in exclude list."""
|
|
205
|
+
connector = Connector(
|
|
206
|
+
transport={},
|
|
207
|
+
config=ConnectionConfig(exclude=["tool2"]),
|
|
208
|
+
name="test",
|
|
209
|
+
connection_type=ConnectionType.REMOTE,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
mock_client = MagicMock()
|
|
213
|
+
mock_client.list_tools = AsyncMock(
|
|
214
|
+
return_value=[
|
|
215
|
+
mcp_types.Tool(name="tool1", description="Tool 1", inputSchema={}),
|
|
216
|
+
mcp_types.Tool(name="tool2", description="Tool 2", inputSchema={}),
|
|
217
|
+
]
|
|
218
|
+
)
|
|
219
|
+
connector.client = mock_client
|
|
220
|
+
|
|
221
|
+
tools = await connector.list_tools()
|
|
222
|
+
|
|
223
|
+
assert len(tools) == 1
|
|
224
|
+
assert tools[0].name == "tool1"
|
|
225
|
+
|
|
226
|
+
@pytest.mark.asyncio
|
|
227
|
+
async def test_list_tools_applies_prefix(self) -> None:
|
|
228
|
+
"""list_tools() adds prefix to tool names."""
|
|
229
|
+
connector = Connector(
|
|
230
|
+
transport={},
|
|
231
|
+
config=ConnectionConfig(prefix="myprefix"),
|
|
232
|
+
name="test",
|
|
233
|
+
connection_type=ConnectionType.REMOTE,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
mock_client = MagicMock()
|
|
237
|
+
mock_client.list_tools = AsyncMock(
|
|
238
|
+
return_value=[
|
|
239
|
+
mcp_types.Tool(name="tool1", description="Tool 1", inputSchema={}),
|
|
240
|
+
]
|
|
241
|
+
)
|
|
242
|
+
connector.client = mock_client
|
|
243
|
+
|
|
244
|
+
tools = await connector.list_tools()
|
|
245
|
+
|
|
246
|
+
assert len(tools) == 1
|
|
247
|
+
assert tools[0].name == "myprefix_tool1"
|
|
248
|
+
|
|
249
|
+
@pytest.mark.asyncio
|
|
250
|
+
async def test_list_tools_caches_results(self) -> None:
|
|
251
|
+
"""list_tools() caches results."""
|
|
252
|
+
connector = Connector(
|
|
253
|
+
transport={},
|
|
254
|
+
config=ConnectionConfig(),
|
|
255
|
+
name="test",
|
|
256
|
+
connection_type=ConnectionType.REMOTE,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
mock_client = MagicMock()
|
|
260
|
+
mock_client.list_tools = AsyncMock(
|
|
261
|
+
return_value=[
|
|
262
|
+
mcp_types.Tool(name="tool1", description="Tool 1", inputSchema={}),
|
|
263
|
+
]
|
|
264
|
+
)
|
|
265
|
+
connector.client = mock_client
|
|
266
|
+
|
|
267
|
+
tools = await connector.list_tools()
|
|
268
|
+
|
|
269
|
+
assert connector._tools_cache == tools
|
|
270
|
+
assert connector.cached_tools == tools
|
|
271
|
+
|
|
272
|
+
@pytest.mark.asyncio
|
|
273
|
+
async def test_call_tool_strips_prefix(self) -> None:
|
|
274
|
+
"""call_tool() strips prefix before calling."""
|
|
275
|
+
connector = Connector(
|
|
276
|
+
transport={},
|
|
277
|
+
config=ConnectionConfig(prefix="myprefix"),
|
|
278
|
+
name="test",
|
|
279
|
+
connection_type=ConnectionType.REMOTE,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
mock_result = mcp_types.CallToolResult(content=[], isError=False)
|
|
283
|
+
mock_client = MagicMock()
|
|
284
|
+
mock_client.call_tool_mcp = AsyncMock(return_value=mock_result)
|
|
285
|
+
connector.client = mock_client
|
|
286
|
+
|
|
287
|
+
await connector.call_tool("myprefix_tool1", {"arg": "value"})
|
|
288
|
+
|
|
289
|
+
# Prefix should be stripped
|
|
290
|
+
mock_client.call_tool_mcp.assert_called_once_with("tool1", {"arg": "value"})
|
|
291
|
+
|
|
292
|
+
@pytest.mark.asyncio
|
|
293
|
+
async def test_call_tool_raises_when_not_connected(self) -> None:
|
|
294
|
+
"""call_tool() raises RuntimeError when not connected."""
|
|
295
|
+
connector = Connector(
|
|
296
|
+
transport={},
|
|
297
|
+
config=ConnectionConfig(),
|
|
298
|
+
name="test",
|
|
299
|
+
connection_type=ConnectionType.REMOTE,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
with pytest.raises(RuntimeError, match="Not connected"):
|
|
303
|
+
await connector.call_tool("tool1", {})
|
|
304
|
+
|
|
305
|
+
def test_repr(self) -> None:
|
|
306
|
+
"""__repr__ shows useful info."""
|
|
307
|
+
connector = Connector(
|
|
308
|
+
transport={},
|
|
309
|
+
config=ConnectionConfig(),
|
|
310
|
+
name="my-server",
|
|
311
|
+
connection_type=ConnectionType.REMOTE,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
repr_str = repr(connector)
|
|
315
|
+
assert "my-server" in repr_str
|
|
316
|
+
assert "remote" in repr_str
|
|
317
|
+
assert "connected=False" in repr_str
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Tests for hud.environment.connectors module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
from hud.environment.connection import ConnectionType, Connector
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestBaseConnectorMixin:
|
|
12
|
+
"""Tests for BaseConnectorMixin._add_connection."""
|
|
13
|
+
|
|
14
|
+
def test_add_connection_stores_transport_config(self) -> None:
|
|
15
|
+
"""_add_connection stores transport, doesn't create client."""
|
|
16
|
+
from hud.environment.connectors.base import BaseConnectorMixin
|
|
17
|
+
|
|
18
|
+
class TestEnv(BaseConnectorMixin):
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._connections: dict[str, Connector] = {}
|
|
21
|
+
|
|
22
|
+
env = TestEnv()
|
|
23
|
+
transport = {"server": {"url": "http://example.com"}}
|
|
24
|
+
|
|
25
|
+
env._add_connection(
|
|
26
|
+
"test-server",
|
|
27
|
+
transport,
|
|
28
|
+
connection_type=ConnectionType.REMOTE,
|
|
29
|
+
auth="test-token",
|
|
30
|
+
prefix="myprefix",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
assert "test-server" in env._connections
|
|
34
|
+
conn = env._connections["test-server"]
|
|
35
|
+
assert conn._transport == transport
|
|
36
|
+
assert conn._auth == "test-token"
|
|
37
|
+
assert conn.config.prefix == "myprefix"
|
|
38
|
+
assert conn.client is None # Not created yet
|
|
39
|
+
|
|
40
|
+
def test_add_connection_returns_self(self) -> None:
|
|
41
|
+
"""_add_connection returns self for chaining."""
|
|
42
|
+
from hud.environment.connectors.base import BaseConnectorMixin
|
|
43
|
+
|
|
44
|
+
class TestEnv(BaseConnectorMixin):
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._connections: dict[str, Connector] = {}
|
|
47
|
+
|
|
48
|
+
env = TestEnv()
|
|
49
|
+
result = env._add_connection(
|
|
50
|
+
"test",
|
|
51
|
+
{},
|
|
52
|
+
connection_type=ConnectionType.REMOTE,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
assert result is env
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestMCPConfigConnectorMixin:
|
|
59
|
+
"""Tests for MCPConfigConnectorMixin."""
|
|
60
|
+
|
|
61
|
+
def test_connect_mcp_detects_local_connection(self) -> None:
|
|
62
|
+
"""connect_mcp detects LOCAL type from command in config."""
|
|
63
|
+
from hud.environment.connectors.mcp_config import MCPConfigConnectorMixin
|
|
64
|
+
|
|
65
|
+
class TestEnv(MCPConfigConnectorMixin):
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
self._connections: dict[str, Connector] = {}
|
|
68
|
+
|
|
69
|
+
env = TestEnv()
|
|
70
|
+
config = {
|
|
71
|
+
"filesystem": {
|
|
72
|
+
"command": "npx",
|
|
73
|
+
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
env.connect_mcp(config)
|
|
78
|
+
|
|
79
|
+
conn = env._connections["filesystem"]
|
|
80
|
+
assert conn.connection_type == ConnectionType.LOCAL
|
|
81
|
+
|
|
82
|
+
def test_connect_mcp_detects_remote_connection(self) -> None:
|
|
83
|
+
"""connect_mcp detects REMOTE type from URL in config."""
|
|
84
|
+
from hud.environment.connectors.mcp_config import MCPConfigConnectorMixin
|
|
85
|
+
|
|
86
|
+
class TestEnv(MCPConfigConnectorMixin):
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
self._connections: dict[str, Connector] = {}
|
|
89
|
+
|
|
90
|
+
env = TestEnv()
|
|
91
|
+
config = {
|
|
92
|
+
"browser": {
|
|
93
|
+
"url": "https://mcp.hud.ai/browser",
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
env.connect_mcp(config)
|
|
98
|
+
|
|
99
|
+
conn = env._connections["browser"]
|
|
100
|
+
assert conn.connection_type == ConnectionType.REMOTE
|
|
101
|
+
|
|
102
|
+
def test_connect_mcp_uses_alias(self) -> None:
|
|
103
|
+
"""connect_mcp uses alias if provided."""
|
|
104
|
+
from hud.environment.connectors.mcp_config import MCPConfigConnectorMixin
|
|
105
|
+
|
|
106
|
+
class TestEnv(MCPConfigConnectorMixin):
|
|
107
|
+
def __init__(self) -> None:
|
|
108
|
+
self._connections: dict[str, Connector] = {}
|
|
109
|
+
|
|
110
|
+
env = TestEnv()
|
|
111
|
+
config = {"server": {"url": "http://example.com"}}
|
|
112
|
+
|
|
113
|
+
env.connect_mcp(config, alias="my-alias")
|
|
114
|
+
|
|
115
|
+
assert "my-alias" in env._connections
|
|
116
|
+
assert "server" not in env._connections
|
|
117
|
+
|
|
118
|
+
def test_connect_mcp_config_creates_multiple_connections(self) -> None:
|
|
119
|
+
"""connect_mcp_config creates a connection for each server."""
|
|
120
|
+
from hud.environment.connectors.mcp_config import MCPConfigConnectorMixin
|
|
121
|
+
|
|
122
|
+
class TestEnv(MCPConfigConnectorMixin):
|
|
123
|
+
def __init__(self) -> None:
|
|
124
|
+
self._connections: dict[str, Connector] = {}
|
|
125
|
+
|
|
126
|
+
env = TestEnv()
|
|
127
|
+
mcp_config = {
|
|
128
|
+
"server1": {"url": "http://example1.com"},
|
|
129
|
+
"server2": {"url": "http://example2.com"},
|
|
130
|
+
"server3": {"command": "npx", "args": ["server"]},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
env.connect_mcp_config(mcp_config)
|
|
134
|
+
|
|
135
|
+
assert len(env._connections) == 3
|
|
136
|
+
assert "server1" in env._connections
|
|
137
|
+
assert "server2" in env._connections
|
|
138
|
+
assert "server3" in env._connections
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TestRemoteConnectorMixin:
|
|
142
|
+
"""Tests for RemoteConnectorMixin."""
|
|
143
|
+
|
|
144
|
+
def test_connect_url_creates_remote_connection(self) -> None:
|
|
145
|
+
"""connect_url creates REMOTE connection."""
|
|
146
|
+
from hud.environment.connectors.remote import RemoteConnectorMixin
|
|
147
|
+
|
|
148
|
+
class TestEnv(RemoteConnectorMixin):
|
|
149
|
+
def __init__(self) -> None:
|
|
150
|
+
self._connections: dict[str, Connector] = {}
|
|
151
|
+
|
|
152
|
+
def mount(self, server: Any, *, prefix: str | None = None) -> None:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
env = TestEnv()
|
|
156
|
+
env.connect_url("https://mcp.example.com", alias="example")
|
|
157
|
+
|
|
158
|
+
assert "example" in env._connections
|
|
159
|
+
conn = env._connections["example"]
|
|
160
|
+
assert conn.connection_type == ConnectionType.REMOTE
|
|
161
|
+
|
|
162
|
+
def test_connect_url_extracts_auth_from_headers(self) -> None:
|
|
163
|
+
"""connect_url extracts Authorization from headers."""
|
|
164
|
+
from hud.environment.connectors.remote import RemoteConnectorMixin
|
|
165
|
+
|
|
166
|
+
class TestEnv(RemoteConnectorMixin):
|
|
167
|
+
def __init__(self) -> None:
|
|
168
|
+
self._connections: dict[str, Connector] = {}
|
|
169
|
+
|
|
170
|
+
def mount(self, server: Any, *, prefix: str | None = None) -> None:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
env = TestEnv()
|
|
174
|
+
env.connect_url(
|
|
175
|
+
"https://mcp.example.com",
|
|
176
|
+
headers={"Authorization": "Bearer my-token"},
|
|
177
|
+
alias="example",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
conn = env._connections["example"]
|
|
181
|
+
assert conn._auth == "Bearer my-token"
|
|
182
|
+
|
|
183
|
+
@patch("httpx.Client")
|
|
184
|
+
def test_connect_hub_fetches_config(self, mock_httpx_cls: MagicMock) -> None:
|
|
185
|
+
"""connect_hub fetches mcp_config from API."""
|
|
186
|
+
from hud.environment.connectors.remote import RemoteConnectorMixin
|
|
187
|
+
|
|
188
|
+
class TestEnv(RemoteConnectorMixin):
|
|
189
|
+
def __init__(self) -> None:
|
|
190
|
+
self._connections: dict[str, Connector] = {}
|
|
191
|
+
|
|
192
|
+
def mount(self, server: Any, *, prefix: str | None = None) -> None:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
# Mock httpx response
|
|
196
|
+
mock_response = MagicMock()
|
|
197
|
+
mock_response.json.return_value = {
|
|
198
|
+
"mcp_config": {
|
|
199
|
+
"browser": {"url": "https://mcp.hud.ai/browser"},
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
mock_response.raise_for_status = MagicMock()
|
|
203
|
+
|
|
204
|
+
mock_client = MagicMock()
|
|
205
|
+
mock_client.get.return_value = mock_response
|
|
206
|
+
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
|
207
|
+
mock_client.__exit__ = MagicMock(return_value=None)
|
|
208
|
+
mock_httpx_cls.return_value = mock_client
|
|
209
|
+
|
|
210
|
+
env = TestEnv()
|
|
211
|
+
with patch("hud.settings.settings") as mock_settings:
|
|
212
|
+
mock_settings.hud_api_url = "https://api.hud.so"
|
|
213
|
+
mock_settings.api_key = "test-key"
|
|
214
|
+
|
|
215
|
+
env.connect_hub("hud/browser")
|
|
216
|
+
|
|
217
|
+
# connect_hub creates a connection named "hud" (the server name)
|
|
218
|
+
assert "hud" in env._connections
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Tests for Environment class - context manager, resources, prompts, prompt feature."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestEnvironmentPrompt:
|
|
9
|
+
"""Tests for Environment.prompt feature."""
|
|
10
|
+
|
|
11
|
+
def test_prompt_defaults_to_none(self) -> None:
|
|
12
|
+
"""Environment.prompt defaults to None."""
|
|
13
|
+
from hud.environment import Environment
|
|
14
|
+
|
|
15
|
+
env = Environment("test")
|
|
16
|
+
assert env.prompt is None
|
|
17
|
+
|
|
18
|
+
def test_prompt_can_be_set(self) -> None:
|
|
19
|
+
"""Environment.prompt can be set manually."""
|
|
20
|
+
from hud.environment import Environment
|
|
21
|
+
|
|
22
|
+
env = Environment("test")
|
|
23
|
+
env.prompt = "Navigate to google.com"
|
|
24
|
+
assert env.prompt == "Navigate to google.com"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestEnvironmentContextManager:
|
|
28
|
+
"""Tests for Environment async context manager."""
|
|
29
|
+
|
|
30
|
+
@pytest.mark.asyncio
|
|
31
|
+
async def test_context_manager_sets_in_context_flag(self) -> None:
|
|
32
|
+
"""Context manager sets _in_context flag."""
|
|
33
|
+
from hud.environment import Environment
|
|
34
|
+
|
|
35
|
+
env = Environment("test")
|
|
36
|
+
|
|
37
|
+
assert env._in_context is False
|
|
38
|
+
|
|
39
|
+
async with env:
|
|
40
|
+
assert env._in_context is True
|
|
41
|
+
|
|
42
|
+
assert env._in_context is False
|
|
43
|
+
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
async def test_context_manager_no_connections(self) -> None:
|
|
46
|
+
"""Context manager works with no connections."""
|
|
47
|
+
from hud.environment import Environment
|
|
48
|
+
|
|
49
|
+
env = Environment("test")
|
|
50
|
+
|
|
51
|
+
async with env:
|
|
52
|
+
# Should work without connections
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestEnvironmentResources:
|
|
57
|
+
"""Tests for Environment resource operations."""
|
|
58
|
+
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_list_resources_empty(self) -> None:
|
|
61
|
+
"""list_resources returns empty list when no resources."""
|
|
62
|
+
from hud.environment import Environment
|
|
63
|
+
|
|
64
|
+
env = Environment("test")
|
|
65
|
+
|
|
66
|
+
async with env:
|
|
67
|
+
resources = await env.list_resources()
|
|
68
|
+
|
|
69
|
+
assert resources == []
|
|
70
|
+
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_read_resource_not_found(self) -> None:
|
|
73
|
+
"""read_resource raises when resource not found."""
|
|
74
|
+
from hud.environment import Environment
|
|
75
|
+
|
|
76
|
+
env = Environment("test")
|
|
77
|
+
|
|
78
|
+
async with env:
|
|
79
|
+
with pytest.raises(ValueError, match="Resource not found"):
|
|
80
|
+
await env.read_resource("file://nonexistent.txt")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TestEnvironmentPrompts:
|
|
84
|
+
"""Tests for Environment prompt operations (MCP prompts, not task prompt)."""
|
|
85
|
+
|
|
86
|
+
@pytest.mark.asyncio
|
|
87
|
+
async def test_list_prompts_empty(self) -> None:
|
|
88
|
+
"""list_prompts returns empty list when no prompts."""
|
|
89
|
+
from hud.environment import Environment
|
|
90
|
+
|
|
91
|
+
env = Environment("test")
|
|
92
|
+
|
|
93
|
+
async with env:
|
|
94
|
+
prompts = await env.list_prompts()
|
|
95
|
+
|
|
96
|
+
assert prompts == []
|
|
97
|
+
|
|
98
|
+
@pytest.mark.asyncio
|
|
99
|
+
async def test_get_prompt_not_found(self) -> None:
|
|
100
|
+
"""get_prompt raises when prompt not found."""
|
|
101
|
+
from hud.environment import Environment
|
|
102
|
+
|
|
103
|
+
env = Environment("test")
|
|
104
|
+
|
|
105
|
+
async with env:
|
|
106
|
+
with pytest.raises(ValueError, match="Prompt not found"):
|
|
107
|
+
await env.get_prompt("nonexistent")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestEnvironmentSetupEvaluate:
|
|
111
|
+
"""Tests for setup_tool and evaluate_tool methods."""
|
|
112
|
+
|
|
113
|
+
def test_setup_tool_with_name_and_kwargs(self) -> None:
|
|
114
|
+
"""setup_tool accepts name and kwargs."""
|
|
115
|
+
from hud.environment import Environment
|
|
116
|
+
|
|
117
|
+
env = Environment("test")
|
|
118
|
+
env.setup_tool("navigate", url="https://example.com")
|
|
119
|
+
|
|
120
|
+
assert len(env._setup_calls) == 1
|
|
121
|
+
assert env._setup_calls[0] == ("navigate", {"url": "https://example.com"})
|
|
122
|
+
|
|
123
|
+
def test_setup_tool_returns_self(self) -> None:
|
|
124
|
+
"""setup_tool returns self for chaining."""
|
|
125
|
+
from hud.environment import Environment
|
|
126
|
+
|
|
127
|
+
env = Environment("test")
|
|
128
|
+
result = env.setup_tool("navigate", url="https://example.com")
|
|
129
|
+
|
|
130
|
+
assert result is env
|
|
131
|
+
|
|
132
|
+
def test_evaluate_tool_with_name_and_kwargs(self) -> None:
|
|
133
|
+
"""evaluate_tool accepts name and kwargs."""
|
|
134
|
+
from hud.environment import Environment
|
|
135
|
+
|
|
136
|
+
env = Environment("test")
|
|
137
|
+
env.evaluate_tool("check_text", contains="success")
|
|
138
|
+
|
|
139
|
+
assert len(env._evaluate_calls) == 1
|
|
140
|
+
assert env._evaluate_calls[0] == ("check_text", {"contains": "success"})
|
|
141
|
+
|
|
142
|
+
def test_evaluate_tool_returns_self(self) -> None:
|
|
143
|
+
"""evaluate_tool returns self for chaining."""
|
|
144
|
+
from hud.environment import Environment
|
|
145
|
+
|
|
146
|
+
env = Environment("test")
|
|
147
|
+
result = env.evaluate_tool("check_text", contains="success")
|
|
148
|
+
|
|
149
|
+
assert result is env
|
|
150
|
+
|
|
151
|
+
def test_chaining_multiple_setup_calls(self) -> None:
|
|
152
|
+
"""Multiple setup_tool calls can be chained."""
|
|
153
|
+
from hud.environment import Environment
|
|
154
|
+
|
|
155
|
+
env = (
|
|
156
|
+
Environment("test")
|
|
157
|
+
.setup_tool("navigate", url="https://example.com")
|
|
158
|
+
.setup_tool("wait", seconds=2)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
assert len(env._setup_calls) == 2
|