New describe_node tool returns the full property bag (bounds, classes, text, state flags, hintText, viewIdResourceName) for a nodeId from the P1 stable-ID scheme. Also resolves that P1 emitted nodeIds but /tap and /scroll ignored them — BridgeCommandHandler now parses nodeId, resolves via ScreenReader.findNodeById, dispatches against the node's bounds center. Closes the end-to-end nodeId contract. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
223 lines
8.6 KiB
Python
223 lines
8.6 KiB
Python
"""
|
|
Unit tests for ``plugin.tools.android_tool.android_describe_node``.
|
|
|
|
Uses only stdlib ``unittest`` + ``unittest.mock`` so it runs cleanly via::
|
|
|
|
python -m unittest plugin.tests.test_android_describe_node
|
|
|
|
without pulling in the ``responses`` dependency that the existing
|
|
``conftest.py`` imports at collection time. The conftest is pytest-only
|
|
and is not loaded by ``unittest``.
|
|
|
|
Coverage:
|
|
* Happy path — bridge returns a full property bag, tool echoes it as JSON.
|
|
* Unknown nodeId — bridge returns ``{"error": "node not found: ..."}``,
|
|
tool surfaces the error string.
|
|
* Missing / empty ``node_id`` argument — tool short-circuits before the
|
|
HTTP call and returns its own guard error, so the relay never gets hit.
|
|
* Schema sanity — the new tool is registered with the expected shape in
|
|
``_SCHEMAS`` / ``_HANDLERS``.
|
|
* Registry wiring — ``_HANDLERS['android_describe_node']`` dispatches
|
|
``args`` dict into the underlying function.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
# Make `import plugin.tools.android_tool` work when the test runs from the
|
|
# repo root without the package being installed. Same bootstrap as
|
|
# test_android_navigate.py.
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
if str(REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
from plugin.tools import android_tool # noqa: E402
|
|
from plugin.tools.android_tool import ( # noqa: E402
|
|
_HANDLERS,
|
|
_SCHEMAS,
|
|
android_describe_node,
|
|
)
|
|
|
|
|
|
class TestAndroidDescribeNodeSchema(unittest.TestCase):
|
|
def test_schema_registered(self) -> None:
|
|
self.assertIn("android_describe_node", _SCHEMAS)
|
|
schema = _SCHEMAS["android_describe_node"]
|
|
self.assertEqual(schema["name"], "android_describe_node")
|
|
self.assertIn("description", schema)
|
|
self.assertIn("parameters", schema)
|
|
|
|
def test_schema_requires_node_id(self) -> None:
|
|
schema = _SCHEMAS["android_describe_node"]
|
|
self.assertEqual(schema["parameters"]["required"], ["node_id"])
|
|
props = schema["parameters"]["properties"]
|
|
self.assertIn("node_id", props)
|
|
self.assertEqual(props["node_id"]["type"], "string")
|
|
|
|
def test_handler_registered(self) -> None:
|
|
self.assertIn("android_describe_node", _HANDLERS)
|
|
|
|
def test_handler_dispatches_args_dict(self) -> None:
|
|
handler = _HANDLERS["android_describe_node"]
|
|
with mock.patch.object(
|
|
android_tool,
|
|
"_post",
|
|
return_value={
|
|
"nodeId": "w0:7",
|
|
"bounds": {"left": 0, "top": 0, "right": 100, "bottom": 100},
|
|
"className": "android.widget.Button",
|
|
"clickable": True,
|
|
},
|
|
) as post_mock:
|
|
raw = handler({"node_id": "w0:7"})
|
|
result = json.loads(raw)
|
|
self.assertEqual(result["nodeId"], "w0:7")
|
|
post_mock.assert_called_once_with("/describe_node", {"nodeId": "w0:7"})
|
|
|
|
|
|
class TestAndroidDescribeNodeHappyPath(unittest.TestCase):
|
|
def test_returns_property_bag(self) -> None:
|
|
bridge_response = {
|
|
"nodeId": "w0:12",
|
|
"bounds": {
|
|
"left": 100,
|
|
"top": 200,
|
|
"right": 300,
|
|
"bottom": 400,
|
|
"centerX": 200,
|
|
"centerY": 300,
|
|
"width": 200,
|
|
"height": 200,
|
|
},
|
|
"className": "android.widget.Switch",
|
|
"text": "Wi-Fi",
|
|
"contentDescription": None,
|
|
"hintText": None,
|
|
"viewIdResourceName": "com.android.settings:id/switch_widget",
|
|
"childCount": 0,
|
|
"clickable": True,
|
|
"longClickable": False,
|
|
"focusable": True,
|
|
"focused": False,
|
|
"editable": False,
|
|
"scrollable": False,
|
|
"checkable": True,
|
|
"checked": True, # NOT null — it's a toggle and it's on
|
|
"enabled": True,
|
|
"selected": False,
|
|
"password": False,
|
|
}
|
|
with mock.patch.object(
|
|
android_tool, "_post", return_value=bridge_response
|
|
) as post_mock:
|
|
raw = android_describe_node("w0:12")
|
|
result = json.loads(raw)
|
|
self.assertEqual(result, bridge_response)
|
|
# Confirm the tool forwards `node_id` as `nodeId` on the wire —
|
|
# this is the Python↔Kotlin body-key contract and the reason we
|
|
# can't just `json.dumps({"node_id": ...})`.
|
|
post_mock.assert_called_once_with("/describe_node", {"nodeId": "w0:12"})
|
|
|
|
def test_checkable_false_means_checked_is_null(self) -> None:
|
|
# When a node isn't a toggle, the Kotlin side emits `checked: null`
|
|
# so the agent can distinguish "not a toggle" from "unchecked toggle".
|
|
# We just verify the tool doesn't mangle that on its way through.
|
|
bridge_response = {
|
|
"nodeId": "w0:3",
|
|
"className": "android.widget.TextView",
|
|
"text": "Hello",
|
|
"checkable": False,
|
|
"checked": None,
|
|
"enabled": True,
|
|
"clickable": False,
|
|
}
|
|
with mock.patch.object(
|
|
android_tool, "_post", return_value=bridge_response
|
|
):
|
|
raw = android_describe_node("w0:3")
|
|
result = json.loads(raw)
|
|
self.assertIsNone(result["checked"])
|
|
self.assertFalse(result["checkable"])
|
|
|
|
|
|
class TestAndroidDescribeNodeUnknownId(unittest.TestCase):
|
|
def test_unknown_node_id_returns_error_payload(self) -> None:
|
|
# The Kotlin handler responds 404 with `{"error": "node not found: ..."}`
|
|
# and the relay's `_bridge_dispatch` bubbles the result object back up
|
|
# with status preserved on the HTTP layer — the raw JSON body is what
|
|
# we actually get back in the tool via `_post(...).json()`.
|
|
with mock.patch.object(
|
|
android_tool,
|
|
"_post",
|
|
return_value={"error": "node not found: w9:999"},
|
|
):
|
|
raw = android_describe_node("w9:999")
|
|
result = json.loads(raw)
|
|
self.assertIn("error", result)
|
|
self.assertIn("not found", result["error"])
|
|
|
|
def test_malformed_node_id_bridge_returns_404(self) -> None:
|
|
# `findNodeById` rejects malformed IDs (not starting with 'w', bad
|
|
# colon placement, non-numeric indices) and surfaces that as a 404
|
|
# error envelope.
|
|
with mock.patch.object(
|
|
android_tool,
|
|
"_post",
|
|
return_value={"error": "node not found: garbage-id"},
|
|
):
|
|
raw = android_describe_node("garbage-id")
|
|
result = json.loads(raw)
|
|
self.assertIn("error", result)
|
|
|
|
|
|
class TestAndroidDescribeNodeMissingArg(unittest.TestCase):
|
|
def test_empty_string_node_id_short_circuits(self) -> None:
|
|
with mock.patch.object(android_tool, "_post") as post_mock:
|
|
raw = android_describe_node("")
|
|
result = json.loads(raw)
|
|
self.assertIn("error", result)
|
|
self.assertIn("node_id is required", result["error"])
|
|
# Crucially, we never hit the wire.
|
|
post_mock.assert_not_called()
|
|
|
|
def test_none_node_id_short_circuits(self) -> None:
|
|
with mock.patch.object(android_tool, "_post") as post_mock:
|
|
# type: ignore[arg-type] — we're deliberately testing the None case
|
|
raw = android_describe_node(None) # type: ignore[arg-type]
|
|
result = json.loads(raw)
|
|
self.assertIn("error", result)
|
|
post_mock.assert_not_called()
|
|
|
|
def test_handler_missing_node_id_raises(self) -> None:
|
|
# The registry handler uses `**args`, so calling it with no node_id
|
|
# should raise a TypeError — this is the contract for "required"
|
|
# parameters at the registry layer.
|
|
handler = _HANDLERS["android_describe_node"]
|
|
with self.assertRaises(TypeError):
|
|
handler({})
|
|
|
|
|
|
class TestAndroidDescribeNodeBridgeErrors(unittest.TestCase):
|
|
def test_bridge_raises_propagates_as_error_json(self) -> None:
|
|
# Network/HTTP failure shouldn't crash the tool — it should echo the
|
|
# exception message in the `{"error": ...}` envelope, matching how
|
|
# every other android_* tool handles _post failures.
|
|
with mock.patch.object(
|
|
android_tool,
|
|
"_post",
|
|
side_effect=RuntimeError("relay unreachable"),
|
|
):
|
|
raw = android_describe_node("w0:1")
|
|
result = json.loads(raw)
|
|
self.assertIn("error", result)
|
|
self.assertEqual(result["error"], "relay unreachable")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|