776 lines
29 KiB
Python
776 lines
29 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("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"]
|
|
)
|