561 lines
23 KiB
Python
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))
|