Files
Artemis-Eapil/tests/unit/mcp/test_native_executor.py
T
chenmingxuan 4ea7939d9c feat(android): add state conditions and measured gesture feedback
Add optional ensure_state, gesture region and final value feedback shared by Flash and Pro. Preserve completed inputs when verification fails and stop repeated ineffective strategies. Regenerate pinned tool schemas for the new optional arguments.
2026-11-02 23:20:34 +08:00

851 lines
31 KiB
Python

"""Native semantics with deterministic clocks and no devices or paid models."""
import asyncio
from copy import deepcopy
from unittest.mock import AsyncMock
from uuid import UUID
import pytest
from artemis.mcp import native_executor
from artemis.mcp.native_executor import execute_native
SELECTOR = {"resource_id": "control"}
CONTAINER = {"resource_id": "list"}
TARGET = {"text": "destination"}
TOKEN = "a" * 64
def node(**overrides):
return {
"token": TOKEN,
"content_signature": "contents-a",
"visible": True,
"enabled": True,
"checkable": True,
"checked": False,
"selected": False,
"password": False,
"text": "exact text",
"actions": ["click", "set_progress", "set_text", "scroll_forward"],
"range": {"min": 0, "max": 10, "current": 3, "type": 1},
**overrides,
}
def observed(**overrides):
return {"success": True, "execution": "observed", "code": "OK", "node": node(**overrides)}
def missing(code="NOT_FOUND"):
return {"success": False, "execution": "not_started", "code": code, "error": code}
def completed(**extra):
return {"success": True, "execution": "completed", "code": "OK", **extra}
class Clock:
def __init__(self):
self.now = 0.0
self.sleeps = []
def monotonic(self):
return self.now
async def sleep(self, seconds):
assert seconds > 0
self.sleeps.append(seconds)
self.now += seconds
@pytest.fixture
def clock(monkeypatch):
fake = Clock()
monkeypatch.setattr(native_executor, "monotonic", fake.monotonic)
monkeypatch.setattr(native_executor, "sleep", fake.sleep)
return fake
async def run(name, args, native):
return await execute_native(name, args, 400, 800, native)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"condition,fields",
[
({"state": "unchecked"}, {"checked": False}),
({"state": "text", "expected_text": " exact "}, {"text": " exact "}),
({"state": "content_desc", "expected_text": "播放"}, {"content_desc": "播放"}),
({"state": "value", "expected_value": 3.0}, {}),
],
)
async def test_ensure_state_already_satisfied_never_toggles(condition, fields):
native = AsyncMock(return_value=observed(**fields))
result = await run(
"control_action",
{"selector": SELECTOR, "action": "click", "ensure_state": condition},
native,
)
assert result["success"] and result["skipped"]
assert result["execution"] == "observed"
assert [call.args[0] for call in native.await_args_list] == ["inspect_control"]
@pytest.mark.asyncio
async def test_ensure_state_missing_native_boolean_refuses_input():
native = AsyncMock(return_value=observed(checked=None))
result = await run(
"control_action",
{"selector": SELECTOR, "action": "click", "ensure_state": {"state": "unchecked"}},
native,
)
assert not result["success"] and result["execution"] == "not_started"
native.assert_awaited_once()
@pytest.mark.asyncio
async def test_ensure_state_writes_once_then_reads_actual_state():
native = AsyncMock(side_effect=[observed(checked=True), completed(), observed(checked=False)])
result = await run(
"control_action",
{"selector": SELECTOR, "action": "click", "ensure_state": {"state": "unchecked"}},
native,
)
assert result["success"] and result["verification"] == "condition_satisfied"
assert [call.args[0] for call in native.await_args_list] == [
"inspect_control",
"control_action",
"inspect_control",
]
args = native.await_args_list[1].args[1]
assert "ensure_state" not in args and "verify_timeout_ms" not in args
assert args["expected_token"] == TOKEN
@pytest.mark.asyncio
async def test_failed_postcondition_preserves_completed_input_without_retoggling(clock):
async def native(command, args, **kwargs):
return completed() if command == "control_action" else observed(checked=True)
native = AsyncMock(side_effect=native)
result = await run(
"control_action",
{
"selector": SELECTOR,
"action": "click",
"ensure_state": {"state": "unchecked"},
"verify_timeout_ms": 400,
},
native,
)
assert not result["success"] and result["code"] == "TIMEOUT"
assert result["execution"] == "completed"
assert result["native_receipt"]["success"]
assert sum(c.args[0] == "control_action" for c in native.await_args_list) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("name", ["pinch", "touch_gesture"])
async def test_all_gesture_geometry_is_validated_before_a_single_native_dispatch(name):
args = (
{
"center": [200, 400],
"start_span": 40,
"end_span": 80,
"rotation_degrees": 45,
"duration_ms": 900,
"coordinate_space": "pixel",
"target_description": "observed control",
}
if name == "pinch"
else {
"frames": [
{"at_ms": 0, "points": [[100, 100]]},
{"at_ms": 300, "points": [[120, 160]]},
{"at_ms": 900, "points": [[140, 220]]},
],
"coordinate_space": "pixel",
"target_description": "observed control",
}
)
native = AsyncMock(return_value=completed(native_marker="original receipt"))
result = await run(name, args, native)
assert result["success"] and result["execution"] == "completed"
assert result["verification"] == "pending_observation"
assert result["native_receipt"]["native_marker"] == "original receipt"
native.assert_awaited_once()
command, sent = native.await_args.args
assert command == "touch_gesture"
assert UUID(sent["operation_id"])
assert sent["frames"] == result["plan"]["frames"]
assert sent["screen_size"] == [400, 800]
assert native.await_args.kwargs == {"timeout": 5.9}
invalid = deepcopy(args)
if name == "pinch":
invalid["center"] = [0, 0]
else:
invalid["frames"][-1]["points"] = [[401, 220]]
never = AsyncMock()
refused = await run(name, invalid, never)
assert not refused["success"] and refused["execution"] == "not_started"
never.assert_not_awaited()
@pytest.mark.asyncio
async def test_touch_timeout_never_replays_the_input():
native = AsyncMock(side_effect=TimeoutError("reply lost"))
result = await run(
"touch_gesture",
{
"frames": [{"at_ms": 0, "points": [[10, 10]]}, {"at_ms": 20, "points": [[20, 30]]}],
"target_description": "observed control",
"require_visible": "required control",
},
native,
)
assert not result["success"] and result["execution"] == "unknown"
assert result["code"] == "TIMEOUT"
native.assert_awaited_once()
assert native.await_args.args[1]["require_visible"] == "required control"
@pytest.mark.asyncio
async def test_read_control_keeps_native_evidence_and_omits_null_selector_fields():
receipt = observed()
native = AsyncMock(return_value=receipt)
result = await run("read_control", {"selector": {**SELECTOR, "text": None}}, native)
native.assert_awaited_once_with("inspect_control", {"selector": SELECTOR}, timeout=None)
assert result["node"] == receipt["node"]
assert result["native_receipt"] == receipt
assert result["execution"] == "observed" and result["action"] == "read_control"
@pytest.mark.asyncio
async def test_control_action_uses_fresh_token_preserves_whitespace_and_does_not_verify_business_value():
before = observed()
after = node(text="different accepted text")
receipt = completed(node_before=before["node"], node_after=after)
native = AsyncMock(side_effect=[before, receipt])
result = await run(
"control_action", {"selector": SELECTOR, "action": "set_text", "text": " exact\n"}, native
)
assert native.await_args_list[1].args == (
"control_action",
{"selector": SELECTOR, "action": "set_text", "text": " exact\n", "expected_token": TOKEN},
)
assert native.await_count == 2
assert result["success"] and result["verification"] == "pending_observation"
assert result["node_before"] == before["node"]
assert result["node_after"] == after
assert result["native_receipt"] == receipt
@pytest.mark.asyncio
@pytest.mark.parametrize(
"snapshot,request_extra,code",
[
(node(), {"expected_token": "b" * 64}, "STALE"),
(node(actions=["long_click"]), {}, "UNSUPPORTED"),
(node(enabled=False), {}, "DISABLED"),
(node(token=None), {}, "INVALID_RESPONSE"),
],
)
async def test_control_action_refuses_stale_unsupported_or_disabled_without_input(
snapshot, request_extra, code
):
native = AsyncMock(return_value={**observed(), "node": snapshot})
result = await run(
"control_action", {"selector": SELECTOR, "action": "click", **request_extra}, native
)
assert not result["success"] and result["code"] == code
assert result["execution"] == "not_started"
assert result["node_before"] == snapshot
native.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"native_range,value",
[
({"min": 0, "max": 10, "current": 3}, 11),
({"min": 0, "max": 10, "current": float("nan")}, 3),
({"min": 0, "max": 10, "current": 3, "type": 0}, 3.5),
],
)
async def test_set_progress_refuses_invalid_live_range_before_input(native_range, value):
native = AsyncMock(return_value=observed(range=native_range))
result = await run(
"control_action", {"selector": SELECTOR, "action": "set_progress", "value": value}, native
)
assert not result["success"] and result["execution"] == "not_started"
native.assert_awaited_once()
@pytest.mark.asyncio
async def test_control_transport_failure_is_unknown_and_never_replayed():
native = AsyncMock(side_effect=[observed(), OSError("lost reply")])
result = await run("control_action", {"selector": SELECTOR, "action": "click"}, native)
assert result["code"] == "NATIVE_ERROR" and result["execution"] == "unknown"
assert not result["success"] and native.await_count == 2
@pytest.mark.asyncio
@pytest.mark.parametrize(
"receipt",
[
"legacy unstructured success",
None,
{"success": True, "execution": [], "code": "OK"},
{"success": True, "execution": "unknown", "code": "OK"},
{"success": True, "execution": "completed"},
],
)
async def test_malformed_write_receipts_are_unknown_and_never_replayed(receipt):
native = AsyncMock(side_effect=[observed(), receipt])
result = await run("control_action", {"selector": SELECTOR, "action": "click"}, native)
assert not result["success"] and result["execution"] == "unknown"
assert result["code"] == "INVALID_RESPONSE" and native.await_count == 2
assert result["native_receipt"] == receipt
@pytest.mark.asyncio
async def test_wait_observes_delayed_condition_without_operating_or_sleeping_after_success(clock):
native = AsyncMock(side_effect=[missing(), observed(visible=False), observed()])
result = await run(
"wait_until",
{"selector": SELECTOR, "state": "visible", "timeout_ms": 1000, "poll_interval_ms": 100},
native,
)
assert result["success"] and result["execution"] == "observed"
assert result["attempts"] == 3 and clock.now == pytest.approx(0.2)
assert [call.args[0] for call in native.await_args_list] == ["inspect_control"] * 3
assert [call.kwargs["timeout"] for call in native.await_args_list] == pytest.approx(
[1, 0.9, 0.8]
)
@pytest.mark.asyncio
@pytest.mark.parametrize("changed_field", ["token", "content_signature"])
async def test_stability_restarts_on_identity_or_descendant_redraw(clock, changed_field):
change = {changed_field: "changed"}
native = AsyncMock(
side_effect=[observed(), observed(**change), observed(**change), observed(**change)]
)
result = await run(
"wait_until",
{
"selector": SELECTOR,
"state": "stable",
"stable_ms": 190,
"timeout_ms": 1000,
"poll_interval_ms": 100,
},
native,
)
assert result["success"] and result["attempts"] == 4
assert clock.now == pytest.approx(0.3)
@pytest.mark.asyncio
async def test_zero_stability_window_still_requires_two_actual_reads(clock):
native = AsyncMock(return_value=observed())
result = await run(
"wait_until", {"selector": SELECTOR, "state": "stable", "stable_ms": 0}, native
)
assert result["success"] and result["attempts"] == 2
assert len(clock.sleeps) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("receipt", [missing(), observed(visible=False)])
async def test_absent_accepts_only_not_found_or_unique_explicitly_invisible(clock, receipt):
native = AsyncMock(return_value=receipt)
result = await run("wait_until", {"selector": SELECTOR, "state": "absent"}, native)
assert result["success"] and result["execution"] == "observed"
native.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"error", ["AMBIGUOUS", "UNSUPPORTED", "LIMIT_EXCEEDED", "INVALID_RESPONSE"]
)
async def test_absent_rejects_nontransient_inspection_errors_immediately(clock, error):
native = AsyncMock(return_value=missing(error))
result = await run("wait_until", {"selector": SELECTOR, "state": "absent"}, native)
assert not result["success"] and result["code"] == error
native.assert_awaited_once()
assert not clock.sleeps
@pytest.mark.asyncio
@pytest.mark.parametrize("error", ["UNAVAILABLE", "READ_FAILED"])
async def test_unreadable_windows_time_out_without_proving_absence(clock, error):
native = AsyncMock(return_value=missing(error))
result = await run(
"wait_until",
{"selector": SELECTOR, "state": "absent", "timeout_ms": 300, "poll_interval_ms": 100},
native,
)
assert not result["success"] and result["code"] == "TIMEOUT"
assert result["native_receipt"]["code"] == error
assert result["attempts"] == 3 and clock.now == pytest.approx(0.3)
assert [sample["code"] for sample in result["observations"]] == [error] * 3
assert [call.args[0] for call in native.await_args_list] == ["inspect_control"] * 3
@pytest.mark.asyncio
@pytest.mark.parametrize("error", ["UNAVAILABLE", "READ_FAILED"])
@pytest.mark.parametrize("state,receipt", [("visible", observed()), ("absent", missing())])
async def test_wait_recovers_only_after_a_reliable_observation(clock, error, state, receipt):
native = AsyncMock(side_effect=[missing(error), receipt])
result = await run(
"wait_until",
{"selector": SELECTOR, "state": state, "timeout_ms": 500, "poll_interval_ms": 100},
native,
)
assert result["success"] and result["execution"] == "observed"
assert result["attempts"] == 2 and clock.now == pytest.approx(0.1)
assert result["observations"][0]["code"] == error
assert result["native_receipt"] == receipt
assert [call.args[0] for call in native.await_args_list] == ["inspect_control"] * 2
@pytest.mark.asyncio
@pytest.mark.parametrize("error", ["UNAVAILABLE", "READ_FAILED"])
async def test_transient_read_failure_restarts_the_full_stability_window(clock, error):
native = AsyncMock(side_effect=[observed(), missing(error), observed(), observed(), observed()])
result = await run(
"wait_until",
{
"selector": SELECTOR,
"state": "stable",
"stable_ms": 190,
"timeout_ms": 1000,
"poll_interval_ms": 100,
},
native,
)
assert result["success"] and result["attempts"] == 5
assert clock.now == pytest.approx(0.4)
assert result["observations"][1]["code"] == error
@pytest.mark.asyncio
async def test_absent_transport_failure_is_not_absence(clock):
native = AsyncMock(side_effect=OSError("unavailable"))
result = await run("wait_until", {"selector": SELECTOR, "state": "absent"}, native)
assert not result["success"] and result["code"] == "NATIVE_ERROR"
native.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize("receipt", [{**observed(), "candidate_count": 2}, observed(visible=None)])
async def test_absent_rejects_nonunique_or_unknown_visibility_observation(clock, receipt):
native = AsyncMock(return_value=receipt)
result = await run("wait_until", {"selector": SELECTOR, "state": "absent"}, native)
assert not result["success"]
assert result["code"] in {"AMBIGUOUS", "INVALID_STATE"}
native.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"state,args,snapshot,success",
[
("text", {"expected_text": " exact text "}, node(), False),
("text", {"expected_text": "exact text"}, node(), True),
("value", {"expected_value": 3.1, "tolerance": 0.2}, node(), True),
(
"value",
{"expected_value": 3},
node(range={"min": 0, "max": float("inf"), "current": 3}),
False,
),
("unchecked", {}, node(checkable=False, checked=False), False),
("checked", {}, node(checked=True), True),
("disabled", {}, node(enabled=False), True),
("unselected", {}, node(selected=False), True),
],
)
async def test_wait_predicates_use_meaningful_exact_native_values(
clock, state, args, snapshot, success
):
native = AsyncMock(return_value={**observed(), "node": snapshot})
result = await run(
"wait_until",
{"selector": SELECTOR, "state": state, "timeout_ms": 400, "stable_ms": 0, **args},
native,
)
assert result["success"] is success
assert all(call.args[0] == "inspect_control" for call in native.await_args_list)
@pytest.mark.asyncio
async def test_wait_deadline_is_bounded_and_preserves_observations(clock):
native = AsyncMock(return_value=observed(visible=False))
result = await run(
"wait_until",
{"selector": SELECTOR, "state": "visible", "timeout_ms": 350, "poll_interval_ms": 100},
native,
)
assert not result["success"] and result["code"] == "TIMEOUT"
assert clock.now == pytest.approx(0.35) and native.await_count == 4
assert len(result["observations"]) == 4
@pytest.mark.asyncio
async def test_wait_rejects_condition_returned_after_monotonic_deadline(clock):
async def native(command, args, *, timeout=None):
clock.now += 0.6
return observed()
result = await run(
"wait_until", {"selector": SELECTOR, "state": "visible", "timeout_ms": 500}, native
)
assert not result["success"] and result["code"] == "TIMEOUT"
assert result["attempts"] == 1
@pytest.mark.asyncio
async def test_wait_stops_a_nonreturning_native_read_at_its_deadline(clock):
calls = []
async def native(command, args, *, timeout=None):
calls.append(command)
await asyncio.Event().wait()
result = await run(
"wait_until",
{"selector": SELECTOR, "state": "visible", "timeout_ms": 20, "stable_ms": 0},
native,
)
assert not result["success"] and result["code"] == "TIMEOUT"
assert calls == ["inspect_control"]
@pytest.mark.asyncio
async def test_long_wait_bounds_evidence_memory_while_retaining_first_and_last_reads(clock):
native = AsyncMock(return_value=observed(visible=False))
result = await run(
"wait_until",
{"selector": SELECTOR, "state": "visible", "timeout_ms": 4000, "poll_interval_ms": 50},
native,
)
assert result["attempts"] > 64 and len(result["observations"]) == 64
assert result["observations"][0]["at_ms"] == 0
assert result["observations"][-1]["at_ms"] > 3000
class ScrollNative:
def __init__(self, clock, *, delayed_target=None, progress=True, receipt=None):
self.clock = clock
self.delayed_target = delayed_target
self.progress = progress
self.receipt = receipt or completed()
self.calls = []
self.writes = 0
async def __call__(self, command, args, *, timeout=None):
self.calls.append((command, deepcopy(args), timeout, self.clock.now))
if command == "control_action":
self.writes += 1
return self.receipt
if args["selector"] == TARGET:
# An unscoped query would spuriously find a same-text external node.
if args.get("scope") != CONTAINER:
return observed(text="external false positive")
if (
self.writes
and self.delayed_target is not None
and self.clock.now >= self.delayed_target
):
return observed(text="destination")
return missing()
assert args == {"selector": CONTAINER}
changed = self.writes and self.progress and self.clock.now >= 0.1
return observed(
token=("b" if self.writes else "a") * 64,
content_signature=f"page-{self.writes}" if changed else "contents-a",
)
def scroll_request(**extra):
return {"container": CONTAINER, "target": TARGET, "settle_ms": 300, "timeout_ms": 2000, **extra}
@pytest.mark.asyncio
async def test_scroll_waits_for_delayed_scoped_target_after_content_changes(clock):
native = ScrollNative(clock, delayed_target=0.2)
result = await run("scroll_to", scroll_request(), native)
assert result["success"] and result["execution"] == "completed"
assert native.writes == 1 and clock.now >= 0.4 - 1e-9
assert result["node"]["text"] == "destination"
assert result["scroll_results"][0]["native_receipt"]["success"]
queries = [
args
for command, args, _, _ in native.calls
if command == "inspect_control" and args["selector"] == TARGET
]
assert queries and all(query["scope"] == CONTAINER for query in queries)
@pytest.mark.asyncio
async def test_scroll_token_change_without_content_change_is_no_progress(clock):
native = ScrollNative(clock, progress=False)
result = await run("scroll_to", scroll_request(), native)
assert not result["success"] and result["code"] == "NO_PROGRESS"
assert result["execution"] == "completed" and native.writes == 1
assert clock.now == pytest.approx(0.5)
assert result["scroll_results"][0]["native_receipt"]["success"]
@pytest.mark.asyncio
async def test_scroll_preserves_each_receipt_and_refreshes_token_with_hard_input_limit(clock):
native = ScrollNative(clock)
result = await run("scroll_to", scroll_request(max_scrolls=2), native)
assert result["code"] == "MAX_SCROLLS" and not result["success"]
assert native.writes == 2 and len(result["scroll_results"]) == 2
writes = [(args, at) for command, args, _, at in native.calls if command == "control_action"]
assert writes[0][0]["expected_token"] == TOKEN
assert writes[1][0]["expected_token"] == "b" * 64
assert writes[1][1] >= 0.3
@pytest.mark.asyncio
@pytest.mark.parametrize("execution", ["unknown", "cancelled"])
async def test_scroll_unknown_or_cancelled_input_is_never_repeated(clock, execution):
receipt = {"success": False, "execution": execution, "code": "TIMEOUT", "native_marker": "kept"}
native = ScrollNative(clock, receipt=receipt)
result = await run("scroll_to", scroll_request(), native)
assert not result["success"] and result["execution"] == execution
assert result["scroll_results"][0]["native_receipt"] == receipt
assert native.writes == 1 and not clock.sleeps
@pytest.mark.asyncio
async def test_scroll_boundary_rejection_is_no_progress_without_retry(clock):
native = ScrollNative(clock, receipt=missing("ACTION_REJECTED"))
result = await run("scroll_to", scroll_request(), native)
assert result["code"] == "NO_PROGRESS" and native.writes == 1
assert result["scroll_results"][0]["native_receipt"]["code"] == "ACTION_REJECTED"
@pytest.mark.asyncio
@pytest.mark.parametrize("which", ["container", "target"])
async def test_scroll_refuses_ambiguous_container_or_scoped_target_before_input(clock, which):
calls = []
async def native(command, args, *, timeout=None):
calls.append(command)
assert command == "inspect_control"
if args["selector"] == (CONTAINER if which == "container" else TARGET):
return missing("AMBIGUOUS")
return observed()
result = await run("scroll_to", scroll_request(), native)
assert not result["success"] and result["code"] == "AMBIGUOUS"
assert "control_action" not in calls
@pytest.mark.asyncio
@pytest.mark.parametrize(
"write_receipt,execution",
[
(completed(native_marker="late accepted input"), "completed"),
(
{
"success": False,
"execution": "unknown",
"code": "TIMEOUT",
"native_marker": "late uncertain receipt",
},
"unknown",
),
],
)
async def test_scroll_drains_dispatched_input_past_search_deadline_without_more_work(
clock, write_receipt, execution
):
calls = []
events = []
pending_input = None
async def deliver_input():
# Device input survives a lost HTTP waiter; cancelling its await cannot undo it.
await asyncio.sleep(0.12)
clock.now = 0.12
events.append("input_delivered")
return write_receipt
async def native(command, args, *, timeout=None):
nonlocal pending_input
calls.append((command, deepcopy(args), timeout))
if command == "inspect_control":
return missing() if args["selector"] == TARGET else observed()
pending_input = asyncio.create_task(deliver_input())
return await asyncio.wait_for(asyncio.shield(pending_input), timeout=timeout)
try:
result = await run(
"scroll_to", scroll_request(timeout_ms=50, settle_ms=50, max_scrolls=3), native
)
events.append("tool_returned")
finally:
if pending_input is not None:
await pending_input
assert events == ["input_delivered", "tool_returned"]
assert not result["success"] and result["code"] == "TIMEOUT"
assert result["execution"] == execution
assert result["scroll_results"][0]["native_receipt"] == write_receipt
assert [command for command, _, _ in calls] == [
"inspect_control",
"inspect_control",
"control_action",
]
assert calls[-1][2] == 5.0
assert not clock.sleeps
@pytest.mark.asyncio
async def test_scroll_timeout_caps_total_search_even_when_content_changes(clock):
native = ScrollNative(clock)
result = await run("scroll_to", scroll_request(timeout_ms=350, settle_ms=300), native)
assert result["code"] == "TIMEOUT" and not result["success"]
assert clock.now == pytest.approx(0.35) and native.writes <= 2
assert len(result["scroll_results"]) == native.writes
@pytest.mark.asyncio
async def test_scroll_requires_visible_target_not_merely_an_existing_scoped_node(clock):
backend = ScrollNative(clock, delayed_target=0.2)
async def native(command, args, *, timeout=None):
result = await backend(command, args, timeout=timeout)
return (
observed(text="destination", visible=False)
if result.get("code") == "NOT_FOUND"
else result
)
result = await run("scroll_to", scroll_request(), native)
assert result["success"] and backend.writes == 1
assert clock.now >= 0.4 - 1e-9
@pytest.mark.asyncio
async def test_scroll_does_not_send_next_input_while_content_is_animating(clock):
writes = []
async def native(command, args, *, timeout=None):
if command == "control_action":
writes.append(clock.now)
return completed()
if args["selector"] == TARGET:
return missing()
page = min(int(clock.now * 10 + 1e-6), 6) if writes else 0
return observed(content_signature=f"animation-{page}")
result = await run("scroll_to", scroll_request(max_scrolls=2), native)
assert result["code"] == "NO_PROGRESS" and len(writes) == 2
assert writes[1] >= 0.9 - 1e-9
@pytest.mark.asyncio
async def test_scroll_transient_visible_target_during_animation_is_not_success(clock):
writes = []
async def native(command, args, *, timeout=None):
if command == "control_action":
writes.append(clock.now)
return completed()
if args["selector"] == TARGET:
return observed() if writes and 0.1 <= clock.now < 0.2 else missing()
return observed(content_signature="after" if writes and clock.now >= 0.25 else "before")
result = await run("scroll_to", scroll_request(max_scrolls=1), native)
assert result["code"] == "MAX_SCROLLS" and not result["success"]
assert len(writes) == 1 and clock.now >= 0.55 - 1e-9
@pytest.mark.asyncio
async def test_scroll_tracks_target_outside_bounded_container_signature_sample(clock):
writes = []
async def native(command, args, *, timeout=None):
if command == "control_action":
writes.append(clock.now)
return completed()
if args["selector"] == TARGET:
return observed() if writes and 0.4 - 1e-9 <= clock.now < 0.45 - 1e-9 else missing()
return observed(content_signature="after" if writes and clock.now >= 0.1 else "before")
result = await run("scroll_to", scroll_request(max_scrolls=1), native)
assert result["code"] == "MAX_SCROLLS" and not result["success"]
assert len(writes) == 1 and clock.now >= 0.75 - 1e-9
@pytest.mark.asyncio
async def test_scroll_read_failure_after_accepted_input_preserves_receipt_without_more_input(clock):
backend = ScrollNative(clock)
async def native(command, args, *, timeout=None):
if command == "inspect_control" and backend.writes:
raise OSError("read unavailable after accepted input")
return await backend(command, args, timeout=timeout)
result = await run("scroll_to", scroll_request(), native)
assert not result["success"] and result["execution"] == "completed"
assert result["code"] == "NATIVE_ERROR" and backend.writes == 1
assert result["scroll_results"][0]["native_receipt"]["success"]
@pytest.mark.asyncio
async def test_cancelled_scroll_settling_never_sends_another_input(clock, monkeypatch):
backend = ScrollNative(clock)
async def cancelled_sleep(seconds):
raise asyncio.CancelledError
monkeypatch.setattr(native_executor, "sleep", cancelled_sleep)
with pytest.raises(asyncio.CancelledError):
await run("scroll_to", scroll_request(), backend)
assert backend.writes == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("name", ["control_action", "wait_until", "scroll_to", "touch_gesture"])
async def test_cancellation_propagates_without_replaying_native_work(clock, name):
requests = {
"control_action": {"selector": SELECTOR, "action": "click"},
"wait_until": {"selector": SELECTOR, "state": "visible"},
"scroll_to": scroll_request(),
"touch_gesture": {
"frames": [{"at_ms": 0, "points": [[1, 1]]}, {"at_ms": 20, "points": [[2, 3]]}],
"target_description": "observed control",
},
}
inputs = []
async def native(command, args, *, timeout=None):
if command == "inspect_control":
if name == "wait_until":
raise asyncio.CancelledError
return missing() if args["selector"] == TARGET else observed()
inputs.append(command)
raise asyncio.CancelledError
with pytest.raises(asyncio.CancelledError):
await run(name, requests[name], native)
assert inputs == (
[]
if name == "wait_until"
else ["touch_gesture" if name == "touch_gesture" else "control_action"]
)