ec2b666284
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
365 lines
12 KiB
Python
365 lines
12 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Unit tests for the SandboxClient class."""
|
|
|
|
import base64
|
|
import json
|
|
import unittest
|
|
from unittest.mock import MagicMock
|
|
from unittest.mock import patch
|
|
|
|
from google.adk.integrations.vmaas.sandbox_client import SandboxClient
|
|
|
|
|
|
def _make_response(data: dict) -> MagicMock:
|
|
"""Create a mock HttpResponse with a JSON body."""
|
|
response = MagicMock()
|
|
response.body = json.dumps(data)
|
|
return response
|
|
|
|
|
|
class TestSandboxClient(unittest.IsolatedAsyncioTestCase):
|
|
"""Tests for SandboxClient."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
self.mock_vertexai_client = MagicMock()
|
|
self.mock_sandbox = MagicMock()
|
|
self.access_token = "test_token_12345"
|
|
self.client = SandboxClient(
|
|
vertexai_client=self.mock_vertexai_client,
|
|
sandbox=self.mock_sandbox,
|
|
access_token=self.access_token,
|
|
)
|
|
|
|
def test_init(self):
|
|
"""Test client initialization."""
|
|
self.assertEqual(self.client._client, self.mock_vertexai_client)
|
|
self.assertEqual(self.client._sandbox, self.mock_sandbox)
|
|
self.assertEqual(self.client._access_token, self.access_token)
|
|
|
|
def test_update_access_token(self):
|
|
"""Test updating access token."""
|
|
new_token = "new_token_67890"
|
|
self.client.update_access_token(new_token)
|
|
self.assertEqual(self.client._access_token, new_token)
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_make_cdp_request(self, mock_to_thread):
|
|
"""Test making a single CDP request."""
|
|
mock_to_thread.return_value = _make_response({"result": "success"})
|
|
|
|
result = await self.client.make_cdp_request(
|
|
"Page.navigate", {"url": "https://example.com"}
|
|
)
|
|
|
|
self.assertEqual(result, {"result": "success"})
|
|
mock_to_thread.assert_called_once()
|
|
call_args = mock_to_thread.call_args
|
|
# First positional arg is the send_command method
|
|
self.assertEqual(
|
|
call_args[0][0],
|
|
self.mock_vertexai_client.agent_engines.sandboxes.send_command,
|
|
)
|
|
# Check keyword args
|
|
self.assertEqual(call_args[1]["http_method"], "POST")
|
|
self.assertEqual(call_args[1]["path"], "cdp")
|
|
self.assertEqual(call_args[1]["access_token"], self.access_token)
|
|
self.assertEqual(call_args[1]["sandbox_environment"], self.mock_sandbox)
|
|
self.assertEqual(
|
|
call_args[1]["request_dict"],
|
|
{"command": "Page.navigate", "params": {"url": "https://example.com"}},
|
|
)
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_make_cdp_batch_request_with_batch_endpoint(
|
|
self, mock_to_thread
|
|
):
|
|
"""Test making a batch CDP request using batch endpoint."""
|
|
mock_to_thread.return_value = _make_response(
|
|
{"results": [{"status": "success"}, {"status": "success"}]}
|
|
)
|
|
|
|
commands = [
|
|
{
|
|
"command": "Input.dispatchMouseEvent",
|
|
"params": {"type": "mousePressed"},
|
|
},
|
|
{
|
|
"command": "Input.dispatchMouseEvent",
|
|
"params": {"type": "mouseReleased"},
|
|
},
|
|
]
|
|
result = await self.client.make_cdp_batch_request(commands)
|
|
|
|
# Should have 2 results from batch
|
|
self.assertEqual(len(result), 2)
|
|
# Should have made 1 call to /cdps
|
|
self.assertEqual(mock_to_thread.call_count, 1)
|
|
call_args = mock_to_thread.call_args
|
|
self.assertEqual(call_args[1]["path"], "cdps")
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_make_cdp_batch_request_fallback_sequential(
|
|
self, mock_to_thread
|
|
):
|
|
"""Test batch CDP falls back to sequential when batch endpoint fails."""
|
|
|
|
# First call (to /cdps) fails with 404
|
|
# Subsequent calls (to /cdp) succeed
|
|
def side_effect(*args, **kwargs):
|
|
if kwargs.get("path") == "cdps":
|
|
raise Exception("404 Not Found")
|
|
return _make_response({"result": "ok"})
|
|
|
|
mock_to_thread.side_effect = side_effect
|
|
|
|
commands = [
|
|
{"command": "Command1", "params": {}},
|
|
{"command": "Command2", "params": {}},
|
|
]
|
|
result = await self.client.make_cdp_batch_request(commands)
|
|
|
|
# Should have 2 results (sequential fallback)
|
|
self.assertEqual(len(result), 2)
|
|
self.assertEqual(result[0]["status"], "success")
|
|
self.assertEqual(result[1]["status"], "success")
|
|
# Should have made 3 calls (1 failed /cdps + 2 sequential /cdp)
|
|
self.assertEqual(mock_to_thread.call_count, 3)
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_get_screenshot(self, mock_to_thread):
|
|
"""Test capturing a screenshot."""
|
|
# Create a simple PNG-like base64 data
|
|
png_data = b"\x89PNG\r\n\x1a\n"
|
|
base64_data = base64.b64encode(png_data).decode()
|
|
|
|
mock_to_thread.return_value = _make_response({"data": base64_data})
|
|
|
|
result = await self.client.get_screenshot()
|
|
|
|
self.assertEqual(result, png_data)
|
|
call_args = mock_to_thread.call_args
|
|
self.assertEqual(
|
|
call_args[1]["request_dict"]["command"], "Page.captureScreenshot"
|
|
)
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_get_current_url(self, mock_to_thread):
|
|
"""Test getting the current URL."""
|
|
mock_to_thread.return_value = _make_response({
|
|
"active_tab_id": "tab1",
|
|
"all_tabs": [
|
|
{"id": "tab1", "url": "https://example.com", "title": "Example"},
|
|
],
|
|
})
|
|
|
|
result = await self.client.get_current_url()
|
|
|
|
self.assertEqual(result, "https://example.com")
|
|
call_args = mock_to_thread.call_args
|
|
self.assertEqual(call_args[1]["path"], "tabs")
|
|
self.assertEqual(call_args[1]["http_method"], "GET")
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_get_current_url_no_active_tab(self, mock_to_thread):
|
|
"""Test getting URL when no tab is active."""
|
|
mock_to_thread.return_value = _make_response({
|
|
"active_tab_id": None,
|
|
"all_tabs": [],
|
|
})
|
|
|
|
result = await self.client.get_current_url()
|
|
|
|
self.assertIsNone(result)
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_navigate(self, mock_to_thread):
|
|
"""Test navigating to a URL."""
|
|
mock_to_thread.return_value = _make_response({"frameId": "frame123"})
|
|
|
|
result = await self.client.navigate("https://example.com")
|
|
|
|
self.assertEqual(result, {"frameId": "frame123"})
|
|
call_args = mock_to_thread.call_args
|
|
self.assertEqual(
|
|
call_args[1]["request_dict"],
|
|
{"command": "Page.navigate", "params": {"url": "https://example.com"}},
|
|
)
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_click_at(self, mock_to_thread):
|
|
"""Test clicking at coordinates."""
|
|
# Return success for batch endpoint
|
|
mock_to_thread.return_value = _make_response({"results": [{}, {}]})
|
|
|
|
await self.client.click_at(100, 200)
|
|
|
|
# Should call batch endpoint
|
|
call_args = mock_to_thread.call_args
|
|
self.assertEqual(call_args[1]["path"], "cdps")
|
|
commands = call_args[1]["request_dict"]["commands"]
|
|
self.assertEqual(len(commands), 2)
|
|
self.assertEqual(commands[0]["params"]["type"], "mousePressed")
|
|
self.assertEqual(commands[1]["params"]["type"], "mouseReleased")
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_hover_at(self, mock_to_thread):
|
|
"""Test hovering at coordinates."""
|
|
mock_to_thread.return_value = _make_response({})
|
|
|
|
await self.client.hover_at(150, 250)
|
|
|
|
call_args = mock_to_thread.call_args
|
|
self.assertEqual(
|
|
call_args[1]["request_dict"]["params"],
|
|
{"type": "mouseMoved", "x": 150, "y": 250},
|
|
)
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_scroll_at_down(self, mock_to_thread):
|
|
"""Test scrolling down."""
|
|
mock_to_thread.return_value = _make_response({})
|
|
|
|
await self.client.scroll_at(100, 200, "down", 300)
|
|
|
|
call_args = mock_to_thread.call_args
|
|
params = call_args[1]["request_dict"]["params"]
|
|
self.assertEqual(params["type"], "mouseWheel")
|
|
self.assertEqual(params["x"], 100)
|
|
self.assertEqual(params["y"], 200)
|
|
self.assertEqual(params["deltaX"], 0)
|
|
self.assertEqual(params["deltaY"], 300) # Positive for down
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_scroll_at_up(self, mock_to_thread):
|
|
"""Test scrolling up."""
|
|
mock_to_thread.return_value = _make_response({})
|
|
|
|
await self.client.scroll_at(100, 200, "up", 300)
|
|
|
|
call_args = mock_to_thread.call_args
|
|
params = call_args[1]["request_dict"]["params"]
|
|
self.assertEqual(params["deltaY"], -300) # Negative for up
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_go_back(self, mock_to_thread):
|
|
"""Test navigating back."""
|
|
# First call returns navigation history, second navigates
|
|
mock_to_thread.side_effect = [
|
|
_make_response({
|
|
"currentIndex": 1,
|
|
"entries": [
|
|
{"id": 1, "url": "https://first.com"},
|
|
{"id": 2, "url": "https://second.com"},
|
|
],
|
|
}),
|
|
_make_response({}), # Navigation response
|
|
]
|
|
|
|
result = await self.client.go_back()
|
|
|
|
self.assertTrue(result)
|
|
self.assertEqual(mock_to_thread.call_count, 2)
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_go_back_at_beginning(self, mock_to_thread):
|
|
"""Test navigating back when at beginning of history."""
|
|
mock_to_thread.return_value = _make_response({
|
|
"currentIndex": 0,
|
|
"entries": [{"id": 1, "url": "https://first.com"}],
|
|
})
|
|
|
|
result = await self.client.go_back()
|
|
|
|
self.assertFalse(result)
|
|
# Should only call once (to get history)
|
|
self.assertEqual(mock_to_thread.call_count, 1)
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_type_text_with_clear_and_enter(self, mock_to_thread):
|
|
"""Test typing text with clear and enter options."""
|
|
# Return success for batch endpoint
|
|
mock_to_thread.return_value = _make_response({"results": [{}] * 7})
|
|
|
|
await self.client.type_text(
|
|
"hello", press_enter=True, clear_before_typing=True
|
|
)
|
|
|
|
# Should have: Ctrl+A down, Ctrl+A up, Delete down, Delete up,
|
|
# insertText, Enter down, Enter up = 7 commands in batch
|
|
call_args = mock_to_thread.call_args
|
|
commands = call_args[1]["request_dict"]["commands"]
|
|
self.assertEqual(len(commands), 7)
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_key_combination(self, mock_to_thread):
|
|
"""Test pressing key combinations."""
|
|
mock_to_thread.return_value = _make_response({"results": [{}] * 4})
|
|
|
|
await self.client.key_combination(["control", "c"])
|
|
|
|
# Should have: Control down, c down, c up, Control up = 4 commands
|
|
call_args = mock_to_thread.call_args
|
|
commands = call_args[1]["request_dict"]["commands"]
|
|
self.assertEqual(len(commands), 4)
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_drag_and_drop(self, mock_to_thread):
|
|
"""Test drag and drop operation."""
|
|
mock_to_thread.return_value = _make_response({"results": [{}] * 4})
|
|
|
|
await self.client.drag_and_drop(10, 20, 100, 200)
|
|
|
|
# Should have: mouseMoved (start), mousePressed, mouseMoved (end),
|
|
# mouseReleased = 4 commands
|
|
call_args = mock_to_thread.call_args
|
|
commands = call_args[1]["request_dict"]["commands"]
|
|
self.assertEqual(len(commands), 4)
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_health_check_healthy(self, mock_to_thread):
|
|
"""Test health check when sandbox is healthy."""
|
|
mock_to_thread.return_value = _make_response({"status": "healthy"})
|
|
|
|
result = await self.client.health_check()
|
|
|
|
self.assertTrue(result)
|
|
call_args = mock_to_thread.call_args
|
|
self.assertEqual(call_args[1]["http_method"], "GET")
|
|
self.assertEqual(call_args[1]["path"], "")
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_health_check_unhealthy(self, mock_to_thread):
|
|
"""Test health check when sandbox is unhealthy."""
|
|
mock_to_thread.return_value = _make_response({"status": "unhealthy"})
|
|
|
|
result = await self.client.health_check()
|
|
|
|
self.assertFalse(result)
|
|
|
|
@patch("asyncio.to_thread")
|
|
async def test_health_check_exception(self, mock_to_thread):
|
|
"""Test health check when request fails."""
|
|
mock_to_thread.side_effect = Exception("Connection failed")
|
|
|
|
result = await self.client.health_check()
|
|
|
|
self.assertFalse(result)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|