Files
Artemis-Eapil/tests/native_actions_fixture/verify_cancellation.py
T

561 lines
23 KiB
Python

"""Explicit device acceptance for cancellation on the already-explored fixture.
Run only when com.artemis.fixture is already foreground with its touch surface
and reset button fully visible, no keyboard, and no covering window:
uv run python tests/native_actions_fixture/verify_cancellation.py --serial SERIAL --output DIR
Writes DIR/result.json even on assertion or precondition failure. Uses the
production device lease, AccessibilityClient connection and AndroidAdbDriver
cancellation path. Does not launch/restart the fixture, dismiss UI, build an APK,
clear data, or replay input. --help never connects to a device.
"""
import argparse
import asyncio
import base64
from copy import deepcopy
from datetime import UTC, datetime
from io import BytesIO
import json
from pathlib import Path
import re
import subprocess
from threading import Lock
import time
from types import SimpleNamespace
from uuid import uuid4
from adbutils import AdbClient
from PIL import Image
from artemis.clients.accessibility_client import AccessibilityClient
from artemis.drivers.android.adb_driver import AndroidAdbDriver
from artemis.mcp.actuators.adb import AdbActuator
from artemis.runtime.adb_endpoint import adb_command
from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock
PACKAGE = "com.artemis.fixture"
STATE_FILE = "files/native_actions_state.json"
class PreconditionError(RuntimeError):
"""Current UI cannot safely exercise this already-explored fixture."""
def stamp() -> dict:
return {"utc": datetime.now(UTC).isoformat(), "monotonic": time.monotonic()}
class RecordingAccessibilityClient(AccessibilityClient):
"""Log real receipts, including the driver's otherwise internal cancel/drain."""
def __init__(self, serial: str) -> None:
super().__init__(serial)
self.receipts: list[dict] = []
self.receipt_lock = Lock()
def native_request(self, command: str, args: dict, *, timeout: float | None = None) -> dict:
record = {
"command": command,
"args": deepcopy(args),
"started": stamp(),
"timeout": timeout,
}
try:
receipt = super().native_request(command, args, timeout=timeout)
record["receipt"] = deepcopy(receipt)
return receipt
except BaseException as exc:
record["error"] = {"type": type(exc).__name__, "message": str(exc)}
raise
finally:
record["ended"] = stamp()
with self.receipt_lock:
self.receipts.append(record)
def receipt_snapshot(self) -> list[dict]:
with self.receipt_lock:
return deepcopy(self.receipts)
def adb_read(serial: str, *args: str) -> str:
return subprocess.run(
adb_command(["-s", serial, "shell", *args]),
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=True,
timeout=5,
).stdout
def require(condition: bool, message: str) -> None:
if not condition:
raise PreconditionError(message)
def bounds(node: dict, width: int, height: int) -> list[int]:
rectangle = node.get("bounds")
require(
isinstance(rectangle, list)
and len(rectangle) == 4
and all(type(value) is int for value in rectangle),
"Native control has no exact pixel bounds; prepare the fixture manually.",
)
left, top, right, bottom = rectangle
require(
0 <= left < right <= width and 0 <= top < bottom <= height,
"Fixture touch/reset control is partially off screen; prepare it manually.",
)
return rectangle
def check_covering_windows(dump: str, rectangles: list[list[int]]) -> None:
"""WindowManager dumps windows top to bottom; refuse uncertain windows above the activity."""
windows = list(re.finditer(r"(?m)^\s*Window #\d+ (Window\{[^\n]+\}):\s*$", dump))
require(bool(windows), "Cannot read WindowManager z-order; covering windows are unknown.")
fixture = [
index
for index, window in enumerate(windows)
if PACKAGE + "/" in window.group(1) and "MainActivity" in window.group(1)
]
require(len(fixture) == 1, "Fixture activity window is missing or ambiguous.")
frame_pattern = r"\b(?:mFrame|frame)=\[(-?\d+),\s*(-?\d+)\]\[(-?\d+),\s*(-?\d+)\]"
for index, window in enumerate(windows[: fixture[0]]):
end = windows[index + 1].start()
block = dump[window.end() : end]
if "mHasSurface=false" in block:
continue
require(
"mHasSurface=true" in block,
f"Window above fixture has unknown surface state: {window.group(1)}",
)
visible = re.search(r"\bisOnScreen=(true|false)\b", block)
if visible is None:
visible = re.search(r"\bisVisible=(true|false)\b", block)
if visible is None:
visible = re.search(r"\bisReadyForDisplay\(\)=(true|false)\b", block)
if (visible is not None and visible.group(1) == "false") or "mViewVisibility=0x8" in block:
continue
frame = re.search(frame_pattern, block)
require(frame is not None, f"Window above fixture has unknown bounds: {window.group(1)}")
left, top, right, bottom = map(int, frame.groups())
overlap = any(
left < rect[2] and right > rect[0] and top < rect[3] and bottom > rect[1]
for rect in rectangles
)
require(
not overlap, f"A visible or uncertain window covers fixture controls: {window.group(1)}"
)
def gesture_args(width: int, height: int, rectangle: list[int]) -> dict:
left, top, right, bottom = rectangle
require(right - left >= 160 and bottom - top >= 100, "Observed touch surface is too small.")
cx, cy = (left + right) // 2, (top + bottom) // 2
span = (right - left) // 6
dx, dy = (right - left) // 12, (bottom - top) // 12
offsets = [(0, 0), (dx, -dy), (0, dy), (-dx, -dy), (0, 0)]
return {
"operation_id": str(uuid4()),
"screen_size": [width, height],
"frames": [
{"at_ms": index * 400, "points": [[cx - span + x, cy + y], [cx + span + x, cy + y]]}
for index, (x, y) in enumerate(offsets)
],
}
async def verify(serial: str, output: Path) -> None:
output.mkdir(parents=True, exist_ok=True)
report = {
"serial": serial,
"fixture_package": PACKAGE,
"started": stamp(),
"status": "running",
"checks": [],
"records": [],
"cleanup_errors": [],
}
client = RecordingAccessibilityClient(serial)
lease = DeviceExecutionLock(serial, description="Native fixture cancellation acceptance")
connection_attempted = False
gesture_task: asyncio.Task | None = None
def check(condition: bool, label: str) -> None:
report["checks"].append({"check": label, "passed": bool(condition), "at": stamp()})
if not condition:
raise AssertionError(label)
async def state(label: str) -> dict:
# MainActivity writes in place after MotionEvents; only JSON decoding is retried.
for attempt in range(5):
raw = await asyncio.to_thread(adb_read, serial, "run-as", PACKAGE, "cat", STATE_FILE)
try:
value = json.loads(raw)
except json.JSONDecodeError as exc:
report["records"].append(
{
"state_read": label,
"attempt": attempt + 1,
"decode_error": str(exc),
"at": stamp(),
}
)
if attempt == 4:
raise
await asyncio.sleep(0.02)
continue
if not isinstance(value, dict) or not isinstance(value.get("touch"), dict):
raise ValueError("Fixture state file has no touch object.")
report["records"].append({"state_read": label, "state": value, "at": stamp()})
return value
raise AssertionError("Unreachable state retry exit")
async def unchanged(expected: dict, label: str, duration: float) -> None:
deadline = time.monotonic() + duration
while True:
observed = await state(label)
check(observed["touch"] == expected, label)
if time.monotonic() >= deadline:
return
await asyncio.sleep(min(0.05, max(0, deadline - time.monotonic())))
try:
lease.acquire(blocking=False)
connection_attempted = True
await asyncio.to_thread(client.connect)
session = client.session
require(
session is not None and session.protocol_version >= 3, "Helper protocol 3 is required."
)
require(
{"inspect_control", "control_action", "touch_gesture", "cancel_gesture"}
<= client.native_capabilities,
"Connected helper lacks the required native capabilities; upgrade separately.",
)
report["helper"] = {"version": session.version_name, "code": session.version_code}
screen = await asyncio.to_thread(client.get_screen_data)
with Image.open(BytesIO(base64.b64decode(screen.base64))) as image:
width, height = image.size
report["screen_size"] = [width, height]
driver = AndroidAdbDriver(serial, AdbClient(), client, width, height)
actuator = AdbActuator(
SimpleNamespace(
device=SimpleNamespace(device_width=width, device_height=height), data_engine=None
),
SimpleNamespace(driver=driver, native_request=driver.native_request),
)
async def tool(name: str, **args) -> dict:
started = stamp()
result = await getattr(actuator, name)(**args)
report["records"].append(
{
"tool": name,
"args": args,
"result": result.model_dump(mode="json"),
"started": started,
"ended": stamp(),
}
)
if name == "read_control":
require(result.ok, f"Native {name} refused: {result.message}")
else:
check(result.ok, f"Native {name} accepted: {result.message}")
return result.metadata
async def preconditions() -> tuple[dict, dict]:
foreground = await driver.get_current_package()
require(
foreground == PACKAGE,
"Fixture is not foreground; no UI will be launched or repaired.",
)
ime = await asyncio.to_thread(adb_read, serial, "dumpsys", "input_method")
ime_flags = re.findall(r"\b(?:mInputShown|mIsInputViewShown)=(?:true|false)\b", ime)
report["records"].append(
{"foreground": foreground, "ime_flags": ime_flags, "at": stamp()}
)
# IME-internal mIsInputViewShown may stay true for a hidden window.
# Only the system's unique mInputShown value establishes visibility.
system_input_shown = re.findall(r"\bmInputShown=(true|false)\b", ime)
require(
system_input_shown == ["false"],
"Keyboard is visible or visibility is unknown; prepare the fixture manually.",
)
nodes = []
for desc in ("fixture.touch", "fixture.touch_reset"):
node = (
await tool(
"read_control", selector={"package_name": PACKAGE, "content_desc": desc}
)
)["node"]
require(
node.get("package_name") == PACKAGE
and node.get("visible") is True
and node.get("enabled") is True,
f"{desc} is not a visible enabled fixture control.",
)
bounds(node, width, height)
nodes.append(node)
require(
"click" in nodes[1].get("actions", []), "Reset control lacks a native click action."
)
windows = await asyncio.to_thread(adb_read, serial, "dumpsys", "window", "windows")
report["records"].append(
{
"window_diagnostics": [
line
for line in windows.splitlines()
if re.search(
r"Window #|mCurrentFocus|mHasSurface|isOnScreen|Frames:|mFrame=", line
)
],
"at": stamp(),
}
)
require(
not re.search(r"\b(?:mShowingLockscreen|mDreamingLockscreen)=true\b", windows),
"A lock screen covers the fixture.",
)
check_covering_windows(windows, [node["bounds"] for node in nodes])
require(
await driver.get_current_package() == PACKAGE,
"Foreground changed during preflight.",
)
return nodes[0], nodes[1]
async def reset() -> tuple[dict, dict]:
_, button = await preconditions()
previous = (await state("before native reset"))["touch"]
require(
previous.get("down_count")
== previous.get("up_count", 0) + previous.get("cancel_count", 0),
"The fixture already has a held touch; do not reset its live counters.",
)
await tool(
"control_action",
selector={"package_name": PACKAGE, "content_desc": "fixture.touch_reset"},
action="click",
expected_token=button["token"],
)
baseline = await state("after native reset")
check(
all(
baseline["touch"].get(key) == 0
for key in (
"down_count",
"up_count",
"cancel_count",
"move_count",
"max_pointer_count",
"pointer_down_count",
"pointer_up_count",
)
),
"native reset cleared app touch counters",
)
check(baseline["touch"].get("events") == [], "native reset cleared app event history")
# Reset publishes a status label and may relayout the activity.
touch, _ = await preconditions()
return touch, baseline
touch, baseline = await reset()
late_args = gesture_args(width, height, touch["bounds"])
cancelled_id = late_args["operation_id"]
for label in ("cancel before registration", "repeated cancel before registration"):
receipt = await driver.native_request(
"cancel_gesture", {"operation_id": cancelled_id}, timeout=2
)
check(
receipt.get("success") is True
and receipt.get("code") == "CANCEL_RECORDED"
and receipt.get("execution") == "observed",
label,
)
receipt = await driver.native_request("touch_gesture", late_args, timeout=6.6)
check(
receipt.get("success") is False
and receipt.get("code") == "CANCELLED"
and receipt.get("execution") == "not_started"
and receipt.get("dispatched_segments") == 0,
"late same-ID gesture rejected before any input",
)
receipt = await driver.native_request(
"cancel_gesture", {"operation_id": cancelled_id}, timeout=2
)
check(
receipt.get("success") is True
and receipt.get("code") == "CANCEL_RECORDED"
and receipt.get("execution") == "observed",
"cancel remains safe after rejected request",
)
await unchanged(
baseline["touch"], "pre-registration cancellation leaves app untouched", 0.15
)
touch, baseline = await reset()
active_args = gesture_args(width, height, touch["bounds"])
active_id = active_args["operation_id"]
report["records"].append({"gesture_task_started": stamp(), "operation_id": active_id})
gesture_task = asyncio.create_task(
driver.native_request("touch_gesture", active_args, timeout=6.6)
)
down_deadline = time.monotonic() + 1.2
while True:
down = (await state("observe app down before Python cancellation"))["touch"]
if down.get("down_count") == 1 and down.get("max_pointer_count") == 2:
check(
down.get("up_count") == 0 and down.get("cancel_count") == 0,
"cancel only while app has an active two-finger contact",
)
break
check(
not gesture_task.done() and time.monotonic() < down_deadline,
"app observes two-finger down before the gesture completes",
)
await asyncio.sleep(0.025)
check(not gesture_task.done(), "production native request still pending at cancellation")
cancel_at = stamp()
report["records"].append(
{"python_task_cancel_requested": cancel_at, "operation_id": active_id}
)
gesture_task.cancel()
try:
await gesture_task
except asyncio.CancelledError:
current = asyncio.current_task()
if current is not None and current.cancelling():
raise
cancelled_at = stamp()
report["records"].append(
{"python_cancelled_error_observed": cancelled_at, "operation_id": active_id}
)
else:
raise AssertionError("Production driver did not propagate CancelledError")
receipts = [
item
for item in client.receipt_snapshot()
if item["args"].get("operation_id") == active_id
]
original = [item for item in receipts if item["command"] == "touch_gesture"]
cancellations = [item for item in receipts if item["command"] == "cancel_gesture"]
check(
len(original) == len(cancellations) == 1,
"driver sends one gesture and one cancellation without replay",
)
final_receipt = original[0].get("receipt", {})
check(
final_receipt.get("code") == "CANCELLED"
and final_receipt.get("execution") == "cancelled"
and 0 < final_receipt.get("dispatched_segments", 0) < len(active_args["frames"]) - 1,
"driver drains the real interrupted gesture receipt",
)
check(
cancellations[0].get("receipt", {}).get("code") == "CANCEL_REQUESTED",
"driver cancellation reaches the active native gesture",
)
check(
original[0]["ended"]["monotonic"] <= cancelled_at["monotonic"],
"CancelledError is observed only after original native request drains",
)
stopped = (await state("immediately after CancelledError"))["touch"]
check(
stopped.get("down_count") == stopped.get("up_count") == 1
and stopped.get("cancel_count") == 0
and stopped.get("max_pointer_count") == 2,
"app received one two-finger down/up and no cancel",
)
check(
stopped.get("pointer_down_count") == stopped.get("pointer_up_count") == 1,
"both native pointers are released exactly once",
)
await unchanged(stopped, "no new app input for 500ms after CancelledError", 0.5)
touch, _ = await preconditions()
for invalid in ("out_of_bounds", "nonincreasing_time"):
args = gesture_args(width, height, touch["bounds"])
if invalid == "out_of_bounds":
args["frames"][-1]["points"][0][0] = width
else:
args["frames"][-1]["at_ms"] = args["frames"][-2]["at_ms"]
receipt = await driver.native_request("touch_gesture", args, timeout=6.6)
check(
receipt.get("success") is False
and receipt.get("code") == "INVALID_ARGUMENT"
and receipt.get("execution") == "not_started"
and receipt.get("dispatched_segments") == 0,
f"raw {invalid} gesture rejected before input",
)
await unchanged(
stopped, f"raw {invalid} rejection leaves app touch state unchanged", 0.1
)
report["status"] = "passed"
except BaseException as exc:
report["status"] = (
"blocked"
if isinstance(exc, (PreconditionError, DeviceBusyError))
else "cancelled"
if isinstance(exc, (asyncio.CancelledError, KeyboardInterrupt))
else "failed"
)
report["error"] = {"type": type(exc).__name__, "message": str(exc)}
raise
finally:
# Never release the device lease while the production driver still drains input.
if gesture_task is not None and not gesture_task.done():
gesture_task.cancel()
while not gesture_task.done():
try:
await asyncio.shield(gesture_task)
except asyncio.CancelledError:
continue
except Exception as exc:
report["cleanup_errors"].append({"stage": "drain gesture", "error": str(exc)})
break
if connection_attempted:
try:
await state("finally")
except BaseException as exc:
report["cleanup_errors"].append({"stage": "final state", "error": str(exc)})
try:
await asyncio.to_thread(client.disconnect)
except BaseException as exc:
report["cleanup_errors"].append({"stage": "disconnect", "error": str(exc)})
try:
lease.release()
except Exception as exc:
report["cleanup_errors"].append({"stage": "lease release", "error": str(exc)})
if report["cleanup_errors"] and report["status"] == "passed":
report["status"] = "failed"
report["native_receipts"] = client.receipt_snapshot()
report["ended"] = stamp()
report["elapsed_seconds"] = report["ended"]["monotonic"] - report["started"]["monotonic"]
(output / "result.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
if report["status"] != "passed":
raise RuntimeError("Acceptance cleanup failed; see result.json.")
print(
f"PASS {len(report['checks'])} checks; receipts and app state: {output / 'result.json'}",
flush=True,
)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"--serial", required=True, help="Explicit already-explored Android device serial"
)
parser.add_argument(
"--output", required=True, type=Path, help="Artifact directory; report is result.json"
)
options = parser.parse_args()
if not options.serial.strip():
parser.error("--serial cannot be blank")
asyncio.run(verify(options.serial, options.output))