feat(android): add native controls and continuous multi-touch

This commit is contained in:
chenmingxuan committed 2026-11-02 21:33:45 +08:00
1 parent 3dd96078db
commit 4b12ae7728
70 files changed
+13105 -179

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,296 @@
package com.artemis.fixture;
import android.app.Activity;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Bundle;
import android.text.Editable;
import android.text.InputType;
import android.text.TextWatcher;
import android.view.MotionEvent;
import android.view.View;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.SeekBar;
import android.widget.TextView;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets;
/** Disposable, dependency-free native controls and observable touch-event fixture. */
public final class MainActivity extends Activity {
private TextView status;
private TextView touchStatus;
private EditText editor;
private SeekBar range;
private TouchSurface touch;
private int clicks;
private int longClicks;
private int imeEnters;
private int statusVersion;
@Override public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setTitle("ARTEMIS native actions fixture");
ScrollView scroll = new ScrollView(this);
scroll.setContentDescription("fixture.scroll");
scroll.setFillViewport(true);
LinearLayout body = new LinearLayout(this);
body.setOrientation(LinearLayout.VERTICAL);
body.setPadding(dp(12), dp(12), dp(12), dp(24));
scroll.addView(body);
body.addView(label("ARTEMIS native controls", "fixture.title"));
status = label("status:ready", "fixture.status");
body.addView(status);
range = new SeekBar(this);
range.setContentDescription("fixture.range");
range.setMax(100);
range.setProgress(25);
body.addView(range, new LinearLayout.LayoutParams(-1, dp(52)));
range.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
updateStatus("range:" + progress);
}
@Override public void onStartTrackingTouch(SeekBar seekBar) {}
@Override public void onStopTrackingTouch(SeekBar seekBar) { writeState(); }
});
Button click = button("Native click", "fixture.click");
click.setOnClickListener(new View.OnClickListener() {
@Override public void onClick(View view) { clicks++; updateStatus("click:" + clicks); }
});
body.addView(click);
Button longClick = button("Native long click", "fixture.long_click");
longClick.setOnLongClickListener(new View.OnLongClickListener() {
@Override public boolean onLongClick(View view) {
longClicks++;
updateStatus("long_click:" + longClicks);
return true;
}
});
body.addView(longClick);
Button delayed = button("Change status after 700 ms", "fixture.delayed");
delayed.setOnClickListener(new View.OnClickListener() {
@Override public void onClick(View view) {
updateStatus("status:waiting");
status.postDelayed(new Runnable() {
@Override public void run() { statusVersion++; updateStatus("status:changed:" + statusVersion); }
}, 700L);
}
});
body.addView(delayed);
editor = new EditText(this) {
@Override protected void onSelectionChanged(int start, int end) {
super.onSelectionChanged(start, end);
writeState();
}
};
editor.setContentDescription("fixture.editor");
editor.setInputType(InputType.TYPE_CLASS_TEXT);
editor.setSingleLine(true);
editor.setImeOptions(EditorInfo.IME_ACTION_DONE);
editor.setText("A\uD83D\uDE00B中文");
editor.setSelection(editor.length());
editor.setOnEditorActionListener(new TextView.OnEditorActionListener() {
@Override public boolean onEditorAction(TextView view, int actionId, android.view.KeyEvent event) {
imeEnters++;
updateStatus("ime_enter:" + imeEnters);
return true;
}
});
editor.addTextChangedListener(new TextWatcher() {
@Override public void beforeTextChanged(CharSequence value, int start, int count, int after) {}
@Override public void onTextChanged(CharSequence value, int start, int before, int count) {}
@Override public void afterTextChanged(Editable value) { writeState(); }
});
body.addView(editor, new LinearLayout.LayoutParams(-1, dp(52)));
touchStatus = label("touch:ready", "fixture.touch_status");
body.addView(touchStatus);
touch = new TouchSurface();
body.addView(touch, new LinearLayout.LayoutParams(-1, dp(210)));
Button reset = button("Reset touch statistics", "fixture.touch_reset");
reset.setOnClickListener(new View.OnClickListener() {
@Override public void onClick(View view) { touch.reset(); writeState(); }
});
body.addView(reset);
// Native ScrollView actions must traverse multiple viewports to reach this target.
for (int i = 0; i < 45; i++) {
TextView row = label("Native list row " + i, "fixture.row." + i);
row.setMinHeight(dp(64));
body.addView(row);
}
Button bottom = button("Bottom target", "fixture.bottom");
bottom.setOnClickListener(new View.OnClickListener() {
@Override public void onClick(View view) { updateStatus("bottom:clicked"); }
});
body.addView(bottom);
setContentView(scroll);
writeState();
}
private int dp(int value) { return Math.round(value * getResources().getDisplayMetrics().density); }
private TextView label(String text, String description) {
TextView result = new TextView(this);
result.setText(text);
result.setTextSize(16);
result.setTextColor(Color.BLACK);
result.setPadding(0, dp(4), 0, dp(4));
result.setContentDescription(description);
return result;
}
private Button button(String text, String description) {
Button result = new Button(this);
result.setText(text);
result.setContentDescription(description);
return result;
}
private void updateStatus(String text) {
status.setText(text);
writeState();
}
/** Read explicitly with: adb shell run-as com.artemis.fixture cat files/native_actions_state.json */
private void writeState() {
if (editor == null || range == null || touch == null) return;
try {
JSONObject state = new JSONObject().put("click_count", clicks).put("long_click_count", longClicks)
.put("ime_enter_count", imeEnters).put("status", status.getText().toString())
.put("progress", range.getProgress()).put("text", editor.getText().toString())
.put("selection_start", editor.getSelectionStart()).put("selection_end", editor.getSelectionEnd())
.put("touch", touch.state(true));
try (FileOutputStream stream = openFileOutput("native_actions_state.json", MODE_PRIVATE)) {
stream.write(state.toString().getBytes(StandardCharsets.UTF_8));
}
} catch (Exception ignored) {}
}
private final class TouchSurface extends View {
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private JSONArray events = new JSONArray();
private int downs;
private int ups;
private int cancels;
private int moves;
private int maxPointers;
private int pointerDowns;
private int pointerUps;
private double initialDistance;
private double initialAngle;
private double scale = 1.0;
private double rotation;
TouchSurface() {
super(MainActivity.this);
setContentDescription("fixture.touch");
setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
setFocusable(true);
setClickable(true);
setBackgroundColor(Color.rgb(208, 239, 247));
paint.setColor(Color.rgb(15, 45, 60));
paint.setTextSize(dp(16));
}
void reset() {
events = new JSONArray();
downs = ups = cancels = moves = maxPointers = pointerDowns = pointerUps = 0;
initialDistance = initialAngle = rotation = 0;
scale = 1;
publish();
}
JSONObject state(boolean includeEvents) throws Exception {
JSONObject result = new JSONObject().put("down_count", downs).put("up_count", ups)
.put("cancel_count", cancels).put("move_count", moves).put("max_pointer_count", maxPointers)
.put("pointer_down_count", pointerDowns).put("pointer_up_count", pointerUps)
.put("scale", scale).put("rotation_degrees", rotation);
if (includeEvents) result.put("events", events);
return result;
}
private void publish() {
try { touchStatus.setText(state(false).toString()); } catch (Exception ignored) {}
invalidate();
writeState();
}
@Override public boolean onTouchEvent(MotionEvent event) {
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
downs++;
initialDistance = 0;
scale = 1;
rotation = 0;
getParent().requestDisallowInterceptTouchEvent(true);
} else if (action == MotionEvent.ACTION_UP) {
ups++;
getParent().requestDisallowInterceptTouchEvent(false);
} else if (action == MotionEvent.ACTION_CANCEL) {
cancels++;
getParent().requestDisallowInterceptTouchEvent(false);
} else if (action == MotionEvent.ACTION_MOVE) {
moves++;
} else if (action == MotionEvent.ACTION_POINTER_DOWN) {
pointerDowns++;
} else if (action == MotionEvent.ACTION_POINTER_UP) {
pointerUps++;
}
int count = event.getPointerCount();
maxPointers = Math.max(maxPointers, count);
if (count >= 2) {
double dx = event.getX(1) - event.getX(0);
double dy = event.getY(1) - event.getY(0);
double distance = Math.hypot(dx, dy);
double angle = Math.toDegrees(Math.atan2(dy, dx));
if (initialDistance == 0 || action == MotionEvent.ACTION_POINTER_DOWN) {
initialDistance = Math.max(distance, 0.001);
initialAngle = angle;
}
scale = distance / initialDistance;
rotation = angle - initialAngle;
while (rotation > 180) rotation -= 360;
while (rotation < -180) rotation += 360;
}
try {
JSONArray pointers = new JSONArray();
for (int i = 0; i < count; i++) {
pointers.put(new JSONObject().put("id", event.getPointerId(i))
.put("x", event.getX(i)).put("y", event.getY(i)));
}
if (events.length() >= 64) events.remove(0);
events.put(new JSONObject().put("action", action).put("time_ms", event.getEventTime())
.put("pointer_count", count).put("pointers", pointers));
} catch (Exception ignored) {}
publish();
return true;
}
@Override protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawText("Touch fixture: pinch, rotate, continuous stroke", dp(8), dp(30), paint);
canvas.drawText("Down " + downs + " / up " + ups + " / moves " + moves,
dp(8), dp(60), paint);
canvas.drawText("Pointers " + maxPointers + " / scale " + String.format(java.util.Locale.ROOT, "%.3f", scale),
dp(8), dp(90), paint);
canvas.drawText("Rotation " + String.format(java.util.Locale.ROOT, "%.1f", rotation), dp(8), dp(120), paint);
}
@Override public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(info);
info.setClassName("com.artemis.fixture.TouchSurface");
}
}
}
@@ -0,0 +1,298 @@
package com.artemis.helper;
import android.accessibilityservice.AccessibilityService;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import org.json.JSONObject;
import java.lang.reflect.Field;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static com.artemis.helper.TestMain.*;
/** Offline scheduler/ownership tests against the real two production classes. */
public final class OwnershipCases {
private OwnershipCases() {}
private static void scenario(String name) {
TestMain.scenario = name;
}
private static void flushMain() throws Exception {
CountDownLatch flushed = new CountDownLatch(1);
new Handler(Looper.getMainLooper()).post(flushed::countDown);
check(flushed.await(2, TimeUnit.SECONDS), "main queue drained");
}
private static final class MainBlock implements AutoCloseable {
final CountDownLatch release = new CountDownLatch(1);
MainBlock() throws Exception {
CountDownLatch entered = new CountDownLatch(1);
new Handler(Looper.getMainLooper()).post(() -> {
entered.countDown();
try {
release.await();
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
});
check(entered.await(2, TimeUnit.SECONDS), "main queue is blocked before the request");
}
@Override public void close() throws Exception {
release.countDown();
flushMain();
}
}
private static int tombstoneCount() throws Exception {
Field registration = TouchGestures.class.getDeclaredField("REGISTRATION");
registration.setAccessible(true);
Field cancelled = TouchGestures.class.getDeclaredField("CANCELLED");
cancelled.setAccessible(true);
synchronized (registration.get(null)) {
return ((Map<?, ?>) cancelled.get(null)).size();
}
}
private static void awaitOwner() throws Exception {
long end = System.currentTimeMillis() + 2000;
while (active() == null && System.currentTimeMillis() < end) Thread.sleep(1);
check(active() != null, "request acquired shared ownership");
}
private static boolean legacy(AccessibilityService service, String kind) {
switch (kind) {
case "tap": return GestureController.tap(service, 100, 200, 1000);
case "double": return GestureController.doubleTap(service, 100, 200, 500);
case "long": return GestureController.longPress(service, 100, 200, 500, 1000);
case "swipe": return GestureController.swipe(service, 100, 200, 200, 300, 100, 1000);
default: throw new AssertionError("Unknown legacy test action");
}
}
private static void cancellationBeforeRegistration() throws Exception {
scenario("cancellation precedes registration and is repeatable");
JSONObject first = cancel("cancel-before-registration");
code(first, "CANCEL_RECORDED", "observed");
eq(first.get("success"), true, "unmatched cancellation acknowledged");
code(cancel("cancel-before-registration"), "CANCEL_RECORDED", "observed");
AccessibilityService untouched = new AccessibilityService();
code(perform(untouched, spec("cancel-before-registration", 20, 2, 1)),
"CANCELLED", "not_started");
eq(untouched.calls, 0, "cancelled ID cannot dispatch when it arrives later");
waitQuiet();
scenario("completed request can be cancelled repeatedly without rewriting its result");
AccessibilityService completed = new AccessibilityService();
JSONObject original = perform(completed, spec("completed-before-cancel", 20, 2, 1));
code(original, "OK", "completed");
code(cancel("completed-before-cancel"), "CANCEL_RECORDED", "observed");
code(cancel("completed-before-cancel"), "CANCEL_RECORDED", "observed");
code(original, "OK", "completed");
AccessibilityService duplicate = new AccessibilityService();
code(perform(duplicate, spec("completed-before-cancel", 20, 2, 1)),
"CANCELLED", "not_started");
eq(duplicate.calls, 0, "repeated cancelled ID cannot inject new input");
}
private static void concurrentRegistrationAndCancellation() throws Exception {
for (int index = 0; index < 32; index++) {
scenario("concurrent registration/cancellation iteration " + index);
final String id = "registration-race-" + index;
final AccessibilityService service = new AccessibilityService();
final CountDownLatch race = new CountDownLatch(1);
Future<JSONObject> operation;
try (MainBlock block = new MainBlock()) {
operation = workers.submit(() -> {
race.await();
return perform(service, spec(id, 20, 2, 1));
});
Future<JSONObject> cancellation = workers.submit(() -> {
race.await();
return cancel(id);
});
race.countDown();
JSONObject acknowledgement = cancellation.get(2, TimeUnit.SECONDS);
String code = (String) acknowledgement.get("code");
check(code.equals("CANCEL_REQUESTED") || code.equals("CANCEL_RECORDED"),
"cancellation wins either registration ordering");
eq(service.calls, 0, "no input before blocked main is released");
}
code(operation.get(2, TimeUnit.SECONDS), "CANCELLED", "not_started");
eq(service.calls, 0, "no input after cancellation and main queue release");
waitQuiet();
}
}
private static void boundedCancellationHistory() throws Exception {
scenario("cancellation tombstones are bounded without unsafe eviction");
SystemClock.advance(60001);
AccessibilityService prune = new AccessibilityService();
code(perform(prune, spec("prune-old-cancellations", 20, 2, 1)), "OK", "completed");
eq(tombstoneCount(), 0, "expired tombstones pruned by registration");
for (int index = 0; index < 70; index++) {
code(cancel("capacity-" + index), "CANCEL_RECORDED", "observed");
check(tombstoneCount() <= 64, "retained cancellation IDs never exceed 64");
}
AccessibilityService evicted = new AccessibilityService();
code(perform(evicted, spec("capacity-0", 20, 2, 1)), "BUSY", "not_started");
eq(evicted.calls, 0, "eviction cannot revive a still-live cancelled request");
AccessibilityService retained = new AccessibilityService();
code(perform(retained, spec("capacity-69", 20, 2, 1)), "CANCELLED", "not_started");
eq(retained.calls, 0, "retained cancelled request cannot start");
AccessibilityService fresh = new AccessibilityService();
code(perform(fresh, spec("capacity-unrelated", 20, 2, 1)), "BUSY", "not_started");
eq(fresh.calls, 0, "capacity barrier blocks unrelated native dispatch");
check(!legacy(fresh, "tap"), "capacity barrier also blocks legacy input");
eq(fresh.calls, 0, "legacy cannot bypass the capacity barrier");
scenario("cancellation TTL expires without a real sixty-second wait");
SystemClock.advance(60001);
code(perform(fresh, spec("after-cancellation-ttl", 20, 2, 1)), "OK", "completed");
eq(tombstoneCount(), 0, "expired IDs no longer occupy bounded storage");
check(legacy(fresh, "tap"), "legacy ownership also recovers after the expiry barrier");
waitQuiet();
}
private static void bidirectionalOwnership() throws Exception {
scenario("active native gesture excludes every legacy gesture route");
final AccessibilityService nativeOwner = new AccessibilityService();
nativeOwner.dropOn = 1;
Future<JSONObject> nativePending = workers.submit(
() -> perform(nativeOwner, spec("native-excludes-legacy", 20, 2, 1)));
waitCalls(nativeOwner, 1);
flushMain();
AccessibilityService legacyOther = new AccessibilityService();
for (String kind : new String[]{"tap", "double", "long", "swipe"}) {
check(!legacy(legacyOther, kind), "native ownership blocks legacy " + kind);
}
eq(legacyOther.calls, 0, "no legacy dispatch bypasses native ownership");
nativeOwner.completeLast();
code(nativePending.get(2, TimeUnit.SECONDS), "OK", "completed");
waitQuiet();
for (String kind : new String[]{"tap", "double", "long", "swipe"}) {
scenario("legacy " + kind + " excludes native gesture until callback");
final AccessibilityService legacyOwner = new AccessibilityService();
legacyOwner.dropOn = 1;
Future<Boolean> pending = workers.submit(() -> legacy(legacyOwner, kind));
waitCalls(legacyOwner, 1);
flushMain();
AccessibilityService nativeOther = new AccessibilityService();
code(perform(nativeOther, spec("legacy-owner-" + kind, 20, 2, 1)), "BUSY", "not_started");
eq(nativeOther.calls, 0, "native cannot interrupt a legacy owner");
if (kind.equals("double")) {
eq(legacyOwner.lastGesture.strokes.size(), 2, "double tap is one two-stroke dispatch");
eq(legacyOwner.lastGesture.strokes.get(1).start, 160L, "second tap has the intended gap");
Thread.sleep(100);
check(active() != null, "double tap retains ownership through the inter-tap gap");
}
legacyOwner.completeLast();
check(pending.get(2, TimeUnit.SECONDS), "legacy callback reports completion");
eq(legacyOwner.calls, 1, "legacy action dispatched exactly once");
waitQuiet();
code(perform(nativeOther, spec("legacy-released-" + kind, 20, 2, 1)), "OK", "completed");
}
scenario("legacy API 24 stays available while continuous gestures remain gated");
Build.VERSION.SDK_INT = 24;
try {
AccessibilityService oldApi = new AccessibilityService();
check(legacy(oldApi, "tap"), "API 24 legacy tap still works");
code(perform(oldApi, spec("native-api24-gate", 20, 2, 1)), "UNSUPPORTED", "not_started");
eq(oldApi.calls, 1, "unsupported continuous gesture does not dispatch");
} finally {
Build.VERSION.SDK_INT = 36;
}
}
private static void queuedLegacyTimeoutAndInterruption() throws Exception {
scenario("legacy main-queued timeout cannot inject after main becomes available");
final AccessibilityService expired = new AccessibilityService();
try (MainBlock block = new MainBlock()) {
Future<Boolean> pending = workers.submit(() -> GestureController.tap(expired, 100, 200, 200));
awaitOwner();
check(!pending.get(2, TimeUnit.SECONDS), "queued legacy wait expires");
eq(expired.calls, 0, "timed-out queued input never started");
check(active() == null, "definitely unstarted timed-out owner is released");
}
eq(expired.calls, 0, "expired runnable cannot inject after main resumes");
check(legacy(expired, "tap"), "fresh legacy work can start after safe queued abandonment");
scenario("interrupted legacy wait abandons the queued runnable");
final AccessibilityService interrupted = new AccessibilityService();
try (MainBlock block = new MainBlock()) {
Future<Boolean> pending = workers.submit(() -> GestureController.tap(interrupted, 100, 200, 1000));
awaitOwner();
check(pending.cancel(true), "legacy worker interruption delivered");
waitQuiet();
}
eq(interrupted.calls, 0, "interrupted queued input cannot run later");
}
private static void unknownAndLateCallbackOwnership() throws Exception {
scenario("legacy timeout retains ownership until a late native callback proves quiet");
final AccessibilityService late = new AccessibilityService();
late.dropOn = 1;
Future<Boolean> pending = workers.submit(() -> GestureController.tap(late, 100, 200, 80));
waitCalls(late, 1);
check(!pending.get(2, TimeUnit.SECONDS), "legacy wait times out without a callback");
check(active() != null, "in-flight timeout keeps ownership");
AccessibilityService other = new AccessibilityService();
code(perform(other, spec("late-legacy-still-busy", 20, 2, 1)), "BUSY", "not_started");
check(!legacy(other, "swipe"), "legacy route cannot bypass the same unknown owner");
eq(other.calls, 0, "unknown input produces no replacement input");
late.completeLast();
waitQuiet();
eq(late.calls, 1, "late callback never replays movement");
code(perform(other, spec("late-legacy-released", 20, 2, 1)), "OK", "completed");
scenario("legacy dispatch exception retains BUSY without a callback");
AccessibilityService unknown = new AccessibilityService();
unknown.throwOn = 1;
check(!legacy(unknown, "tap"), "thrown dispatch does not report completion");
check(active() != null, "thrown dispatch remains owned");
AccessibilityService untouched = new AccessibilityService();
code(perform(untouched, spec("legacy-throw-busy", 20, 2, 1)), "BUSY", "not_started");
check(!legacy(untouched, "double"), "legacy input cannot bypass dispatch uncertainty");
eq(untouched.calls, 0, "unknown throw cannot cause replacement input");
resetUnknown(); // Test isolation only: production has no blind ownership reset.
scenario("late callback after a legacy dispatch exception releases ownership");
AccessibilityService throwsThenCompletes = new AccessibilityService();
throwsThenCompletes.throwAndCompleteOn = 1;
throwsThenCompletes.dropOn = 1;
check(!legacy(throwsThenCompletes, "tap"), "exception stays false even when callback arrives later");
check(active() != null, "throw awaits callback before releasing");
throwsThenCompletes.completeLast();
waitQuiet();
eq(throwsThenCompletes.calls, 1, "callback after throw does not resend input");
check(legacy(new AccessibilityService(), "tap"), "new input is admitted after confirmed callback");
scenario("uncertain native gesture excludes legacy input too");
AccessibilityService nativeUnknown = new AccessibilityService();
nativeUnknown.throwOn = 1;
code(perform(nativeUnknown, spec("unknown-native-legacy-gate", 20, 2, 1)), "DISPATCH_ERROR", "unknown");
check(active() != null, "native uncertainty retains shared ownership");
AccessibilityService legacyUntouched = new AccessibilityService();
check(!legacy(legacyUntouched, "tap"), "legacy cannot bypass uncertain native ownership");
eq(legacyUntouched.calls, 0, "native uncertainty admits no legacy dispatch");
resetUnknown();
}
public static void run() throws Exception {
cancellationBeforeRegistration();
concurrentRegistrationAndCancellation();
boundedCancellationHistory();
bidirectionalOwnership();
queuedLegacyTimeoutAndInterruption();
unknownAndLateCallbackOwnership();
}
}
+19
View File
@@ -0,0 +1,19 @@
# 原生能力隔离验收
夹具无网络与额外权限,包名 `com.artemis.fixture`,只记录自身控件值与触摸事件。需要已安装的 Android SDK/JDK,不会下载依赖。生成物写入 `artifacts/`,不提交设备输出。
1. `uv run python tests/native_actions_fixture/build.py --output artifacts/native-actions-fixture/fixture.apk`
2. 在指定且空闲设备安装该 APK;启动 `com.artemis.fixture/.MainActivity`。
3. 使用 ARTEMIS 探索该页面:`fixture.range`、`fixture.editor`、`fixture.scroll`、`fixture.bottom` 与 `fixture.touch`。
4. `uv run python tests/native_actions_fixture/verify_device.py --serial SERIAL --output artifacts/native-actions-fixture/verification --repeats 5`
5. 使用正式 Flash/Pro 复跑,并检查工具记录、APP 数值与最终验收;探索结果不能代替正式模式。
设备脚本获取现有设备锁,通过生产 driver/actuator 执行;会正常升级打包的辅助服务,并重建**自身测试 Activity**使计数可重复,不清空数据、不停止用户 APP。它验证精确设值、选区/替换/IME、条件等待、五轮缩放/旋转/连续接触及两轮往返滚动;软键盘关闭后再测试触摸区域。结果 JSON 保留每步回执,失败样本不删除。脚本保留夹具供取证,测试结束后由操作者恢复原 APP;需要卸载时仅卸载夹具。
`uv run python tests/native_actions_fixture/verify_cancellation.py --serial SERIAL --output artifacts/native-actions-fixture/cancellation` 验证先取消后注册、执行中的双指取消、实际释放及返回后没有新增触点。它要求夹具已在前台、触摸区与重置按钮完整可见、键盘关闭且无窗口遮挡;前置条件不足会写 blocked 报告,不自动修复界面。判断键盘以系统 `mInputShown` 为准,IME 内部 `mIsInputViewShown` 不能单独证明窗口可见。
`uv run python tests/native_actions_fixture/touch_jvm_harness.py` 在临时目录编译实际 Java 源码并模拟回调,验证取消、迟到回调与输入所有权。它不连接设备,不能证明 Android 的实际触摸交付。
`uv run python tests/native_actions_fixture/control_windows_jvm_harness.py` 编译生产窗口解析及原生控件实现,模拟多窗口唯一性、读失败、搜索预算、取消与写入遮挡。覆盖实际选区工具栏/手柄形状、区域空洞及多个遮挡合并;同样不操作设备。
构建脚本使用临时测试签名。重建后签名可能变化;如安装提示签名冲突,可卸载**这个夹具**再装,不卸载用户 APP 或 ARTEMIS 助手来绕过问题。
+151
View File
@@ -0,0 +1,151 @@
"""Build the disposable native fixture using an existing Android SDK/JDK only."""
import argparse
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import zipfile
def build(output: Path) -> Path:
sdk = Path(
os.environ.get("ANDROID_HOME")
or os.environ.get("ANDROID_SDK_ROOT")
or Path.home() / "AppData/Local/Android/Sdk"
)
versions = sorted(
(sdk / "build-tools").glob("*"),
key=lambda p: tuple(int(v) for v in p.name.split(".") if v.isdigit()),
)
platforms = sorted(
(sdk / "platforms").glob("android-*"), key=lambda p: int(p.name.split("-")[-1])
)
if not versions or not platforms:
raise RuntimeError("An existing Android SDK with platform and build-tools is required.")
tools = versions[-1]
android = platforms[-1] / "android.jar"
executable = ".exe" if os.name == "nt" else ""
def run(*args: object) -> None:
subprocess.run([str(arg) for arg in args], check=True, timeout=90)
java = shutil.which("java")
javac = shutil.which("javac")
keytool = shutil.which("keytool")
if not java or not javac or not keytool:
raise RuntimeError("An existing JDK (java, javac and keytool) is required.")
output = output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="artemis-native-fixture-") as temporary:
scratch = Path(temporary)
classes = scratch / "classes"
classes.mkdir()
manifest = scratch / "AndroidManifest.xml"
manifest.write_text(
"""<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.artemis.fixture">
<application android:label="ARTEMIS Native Fixture" android:debuggable="true" android:allowBackup="false" android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity android:name=".MainActivity" android:exported="true" android:windowSoftInputMode="adjustResize">
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
</activity>
</application>
</manifest>""",
encoding="utf-8",
)
unsigned = scratch / "unsigned.apk"
run(
tools / ("aapt2" + executable),
"link",
"-I",
android,
"--manifest",
manifest,
"--min-sdk-version",
26,
"--target-sdk-version",
35,
"--version-code",
1,
"--version-name",
"1.0",
"-o",
unsigned,
)
run(
javac,
"-encoding",
"UTF-8",
"-source",
8,
"-target",
8,
"-Xlint:-options",
"-cp",
android,
"-d",
classes,
Path(__file__).with_name("MainActivity.java"),
)
run(
java,
"-cp",
tools / "lib/d8.jar",
"com.android.tools.r8.D8",
"--lib",
android,
"--min-api",
26,
"--output",
scratch,
*classes.rglob("*.class"),
)
with zipfile.ZipFile(unsigned, "a", compression=zipfile.ZIP_DEFLATED) as archive:
archive.write(scratch / "classes.dex", "classes.dex")
aligned = scratch / "aligned.apk"
run(tools / ("zipalign" + executable), "-f", "-p", 4, unsigned, aligned)
key = scratch / "fixture.keystore"
# Public throwaway debug signing material, never reused for distribution.
run(
keytool,
"-genkeypair",
"-keystore",
key,
"-storepass",
"android",
"-keypass",
"android",
"-alias",
"fixture",
"-keyalg",
"RSA",
"-validity",
365,
"-dname",
"CN=ARTEMIS Test Fixture",
"-noprompt",
)
run(
java,
"-jar",
tools / "lib/apksigner.jar",
"sign",
"--ks",
key,
"--ks-pass",
"pass:android",
"--key-pass",
"pass:android",
"--ks-key-alias",
"fixture",
"--out",
output,
aligned,
)
return output
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
print(build(parser.parse_args().output))
@@ -0,0 +1,532 @@
"""Explicit offline native-window integration checks; requires an existing JDK only.
Run with ``uv run python tests/native_actions_fixture/control_windows_jvm_harness.py``.
Compiles the repository's actual NativeControls/ControlWindowRoots against small
Android fakes. No device, service, network, APK, or configured model is accessed.
Generated sources/classes live only in a self-cleaning temporary directory.
"""
from pathlib import Path
from tempfile import TemporaryDirectory
from time import perf_counter
from touch_jvm_harness import find_tool, run_command
STUBS = {
"android/os/Build.java": """
package android.os;
public final class Build {public static final class VERSION {public static int SDK_INT=36;}}
""",
"android/os/Looper.java": """
package android.os;
public final class Looper {private static final Looper MAIN=new Looper();
public static Looper getMainLooper(){return MAIN;}public static Looper myLooper(){return MAIN;}}
""",
"android/os/Handler.java": """
package android.os;
public final class Handler {public Handler(Looper l){}public boolean post(Runnable r){r.run();return true;}
public void removeCallbacks(Runnable r){}}
""",
"android/os/Bundle.java": """
package android.os;import java.util.*;
public final class Bundle {private final Map<String,Object> values=new HashMap<>();
public void putFloat(String k,float v){values.put(k,v);}public void putInt(String k,int v){values.put(k,v);}
public void putCharSequence(String k,CharSequence v){values.put(k,v);}public Object get(String k){return values.get(k);}}
""",
"android/graphics/Rect.java": """
package android.graphics;
public final class Rect {public int left,top,right,bottom;public Rect(){}
public Rect(int l,int t,int r,int b){left=l;top=t;right=r;bottom=b;}
public boolean isEmpty(){return left>=right||top>=bottom;}
public static boolean intersects(Rect a,Rect b){return a.left<b.right&&b.left<a.right&&a.top<b.bottom&&b.top<a.bottom;}
public String flattenToString(){return left+" "+top+" "+right+" "+bottom;}
@Override public boolean equals(Object o){if(!(o instanceof Rect))return false;Rect r=(Rect)o;
return left==r.left&&top==r.top&&right==r.right&&bottom==r.bottom;}
@Override public int hashCode(){return left+31*top+961*right+29791*bottom;}}
""",
"android/graphics/Region.java": """
package android.graphics;import java.awt.geom.Area;import java.awt.geom.Rectangle2D;
// Independent standard-JDK geometry implements the Android Region operations used by the real helper.
public final class Region {
public enum Op {INTERSECT,DIFFERENCE,UNION}
private Area area=new Area();public Region(){}public Region(Rect r){set(r);}
private static Area rect(Rect r){return r.isEmpty()?new Area():new Area(new Rectangle2D.Double(
r.left,r.top,(double)r.right-r.left,(double)r.bottom-r.top));}
public boolean set(Rect r){area=rect(r);return !isEmpty();}
public boolean set(Region r){area=new Area(r.area);return !isEmpty();}
public void setEmpty(){area=new Area();}public boolean isEmpty(){return area.isEmpty();}
public boolean op(Rect r,Op op){Region other=new Region(r);return op(other,op);}
public boolean op(Region r,Op op){Area other=new Area(r.area);
if(op==Op.INTERSECT)area.intersect(other);else if(op==Op.DIFFERENCE)area.subtract(other);else area.add(other);
return !isEmpty();}
@Override public boolean equals(Object value){if(!(value instanceof Region))return false;
Area difference=new Area(area);difference.exclusiveOr(((Region)value).area);return difference.isEmpty();}
@Override public int hashCode(){return 0;}
}
""",
"org/json/JSONArray.java": """
package org.json;import java.util.*;
public final class JSONArray {private final List<Object> values=new ArrayList<>();
public JSONArray put(Object v){values.add(v);return this;}public int length(){return values.size();}
public Object get(int i){return values.get(i);}public JSONObject getJSONObject(int i){return (JSONObject)get(i);}
@Override public String toString(){return values.toString();}}
""",
"org/json/JSONObject.java": """
package org.json;import java.util.*;
public final class JSONObject {public static final Object NULL=new Object(){public String toString(){return "null";}};
private final Map<String,Object> values=new LinkedHashMap<>();
public JSONObject put(String k,Object v){values.put(k,v==null?NULL:v);return this;}
public boolean has(String k){return values.containsKey(k);}public Object opt(String k){return values.get(k);}
public Object get(String k){if(!has(k))throw new IllegalArgumentException(k);return opt(k);}
public String getString(String k){return (String)get(k);}public int getInt(String k){return ((Number)get(k)).intValue();}
public boolean getBoolean(String k){return (Boolean)get(k);}
public JSONObject getJSONObject(String k){return (JSONObject)get(k);}
public JSONArray getJSONArray(String k){return (JSONArray)get(k);}
public JSONObject optJSONObject(String k){Object v=opt(k);return v instanceof JSONObject?(JSONObject)v:null;}
public JSONArray optJSONArray(String k){Object v=opt(k);return v instanceof JSONArray?(JSONArray)v:null;}
public boolean optBoolean(String k,boolean d){Object v=opt(k);return v instanceof Boolean?(Boolean)v:d;}
public Iterator<String> keys(){return values.keySet().iterator();}
@Override public String toString(){return values.toString();}}
""",
"android/view/accessibility/AccessibilityNodeInfo.java": """
package android.view.accessibility;
import android.graphics.Rect;import android.os.Bundle;import java.util.*;
public final class AccessibilityNodeInfo {
public static final int ACTION_CLICK=16,ACTION_LONG_CLICK=32,ACTION_FOCUS=1,ACTION_SCROLL_FORWARD=4096,
ACTION_SCROLL_BACKWARD=8192,ACTION_SET_SELECTION=131072,ACTION_SET_TEXT=2097152;
public static final String ACTION_ARGUMENT_PROGRESS_VALUE="progress",ACTION_ARGUMENT_SELECTION_START_INT="start",
ACTION_ARGUMENT_SELECTION_END_INT="end",ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE="text";
public static int live,acquired,recycled,doubleRecycle;
public static final class Data {
public final int identity,window;public String resource="",text="",pkg="app",cls="FrameLayout";
public boolean packageThrows,childThrows,editable,scrollSupport,disableAfterText,visible=true,enabled=true,refresh=true;
public int left=10,top=100,right=110,bottom=200,selectionStart=0,selectionEnd=0;
public int actionsPerformed;public Data parent;public final List<Data> children=new ArrayList<>();
public Data(int id,int win){identity=id;window=win;}
public Data add(Data child){children.add(child);if(child!=null)child.parent=this;return this;}
}
private final Data data;private boolean released;
public AccessibilityNodeInfo(Data d){data=d;live++;acquired++;}
public static AccessibilityNodeInfo obtain(AccessibilityNodeInfo n){n.valid();return new AccessibilityNodeInfo(n.data);}
private void valid(){if(released)throw new IllegalStateException("recycled node reused");}
public void recycle(){if(released){doubleRecycle++;throw new IllegalStateException("double recycle");}
released=true;live--;recycled++;}
public int getWindowId(){valid();return data.window;}
public CharSequence getPackageName(){valid();if(data.packageThrows)throw new IllegalStateException("package failed");return data.pkg;}
public CharSequence getViewIdResourceName(){valid();return data.resource;}
public CharSequence getClassName(){valid();return data.cls;}public CharSequence getText(){valid();return data.text;}
public CharSequence getContentDescription(){valid();return "";}
public int getChildCount(){valid();return data.children.size();}
public AccessibilityNodeInfo getChild(int index){valid();if(data.childThrows)throw new IllegalStateException("child failed");
Data d=data.children.get(index);return d==null?null:new AccessibilityNodeInfo(d);}
public AccessibilityNodeInfo getParent(){valid();return data.parent==null?null:new AccessibilityNodeInfo(data.parent);}
public void getBoundsInScreen(Rect r){valid();r.left=data.left;r.top=data.top;r.right=data.right;r.bottom=data.bottom;}
public boolean refresh(){valid();return data.refresh;}
public boolean performAction(int id,Bundle args){valid();data.actionsPerformed++;
if(id==ACTION_SET_TEXT){data.text=(String)args.get(ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE);
if(data.disableAfterText)data.enabled=false;}
if(id==ACTION_SET_SELECTION){data.selectionStart=(Integer)args.get(ACTION_ARGUMENT_SELECTION_START_INT);
data.selectionEnd=(Integer)args.get(ACTION_ARGUMENT_SELECTION_END_INT);}return true;}
public boolean isVisibleToUser(){valid();return data.visible;}public boolean isEnabled(){valid();return data.enabled;}
public boolean isEditable(){valid();return data.editable;}public boolean isPassword(){valid();return false;}
public boolean isChecked(){valid();return false;}public boolean isCheckable(){valid();return false;}
public boolean isSelected(){valid();return false;}public boolean isFocused(){valid();return false;}
public boolean isScrollable(){valid();return true;}public boolean isShowingHintText(){valid();return false;}
public int getTextSelectionStart(){valid();return data.selectionStart;}public int getTextSelectionEnd(){valid();return data.selectionEnd;}
public RangeInfo getRangeInfo(){valid();return null;}
public List<AccessibilityAction> getActionList(){valid();List<AccessibilityAction> actions=new ArrayList<>();
actions.add(new AccessibilityAction(ACTION_CLICK));if(data.editable){actions.add(new AccessibilityAction(ACTION_SET_TEXT));
actions.add(new AccessibilityAction(ACTION_SET_SELECTION));}if(data.scrollSupport)actions.add(new AccessibilityAction(ACTION_SCROLL_FORWARD));
return actions;}
@Override public boolean equals(Object o){return o instanceof AccessibilityNodeInfo
&& data.identity==((AccessibilityNodeInfo)o).data.identity&&data.window==((AccessibilityNodeInfo)o).data.window;}
@Override public int hashCode(){return data.identity*31+data.window;}
public static final class RangeInfo {public static final int RANGE_TYPE_INT=0;
public float getMin(){return 0;}public float getMax(){return 100;}public float getCurrent(){return 50;}public int getType(){return 0;}}
public static final class AccessibilityAction {private final int id;public AccessibilityAction(int i){id=i;}public int getId(){return id;}
public static final AccessibilityAction ACTION_SHOW_ON_SCREEN=new AccessibilityAction(16908342),
ACTION_SET_PROGRESS=new AccessibilityAction(16908349),ACTION_SCROLL_UP=new AccessibilityAction(16908344),
ACTION_SCROLL_DOWN=new AccessibilityAction(16908346),ACTION_SCROLL_LEFT=new AccessibilityAction(16908345),
ACTION_SCROLL_RIGHT=new AccessibilityAction(16908347),ACTION_IME_ENTER=new AccessibilityAction(16908372);}
}
""",
"android/view/accessibility/AccessibilityWindowInfo.java": """
package android.view.accessibility;import android.graphics.Rect;import android.graphics.Region;
public final class AccessibilityWindowInfo {
public static final int TYPE_APPLICATION=1,TYPE_INPUT_METHOD=2,TYPE_SYSTEM=3;
public static int live,recycled,doubleRecycle;
public static final class Data {
public final int id,layer;public int type=TYPE_APPLICATION,display;
public boolean active,rootThrows,metadataThrows,boundsThrow,regionThrows;
public int[][] regions;
public int left=0,top=0,right=1080,bottom=2400;
public AccessibilityNodeInfo.Data root;public Data(int i,int l,boolean a,AccessibilityNodeInfo.Data r){id=i;layer=l;active=a;root=r;}
}
private final Data data;private boolean released;
public AccessibilityWindowInfo(Data d){data=d;live++;}
private void valid(){if(released)throw new IllegalStateException("recycled window reused");
if(data.metadataThrows)throw new IllegalStateException("metadata unavailable");}
public int getId(){valid();return data.id;}public int getType(){valid();return data.type;}
public int getLayer(){valid();return data.layer;}public int getDisplayId(){valid();return data.display;}
public boolean isActive(){valid();return data.active;}
public void getBoundsInScreen(Rect r){valid();if(data.boundsThrow)throw new IllegalStateException("bounds failed");
r.left=data.left;r.top=data.top;r.right=data.right;r.bottom=data.bottom;}
public void getRegionInScreen(Region r){valid();if(data.regionThrows)throw new IllegalStateException("region failed");
if(data.regions==null){r.set(new Rect(data.left,data.top,data.right,data.bottom));return;}
r.setEmpty();for(int[] part:data.regions)r.op(new Rect(part[0],part[1],part[2],part[3]),Region.Op.UNION);}
public AccessibilityNodeInfo getRoot(){valid();if(data.rootThrows)throw new IllegalStateException("root failed");
return data.root==null?null:new AccessibilityNodeInfo(data.root);}
public void recycle(){if(released){doubleRecycle++;throw new IllegalStateException("double window recycle");}
released=true;live--;recycled++;}
}
""",
"android/accessibilityservice/AccessibilityService.java": """
package android.accessibilityservice;
import android.view.accessibility.*;import java.util.*;
public final class AccessibilityService {
public AccessibilityNodeInfo.Data active;public final List<AccessibilityWindowInfo.Data> windows=new ArrayList<>();
public boolean windowsThrow,windowsNull,activeThrows;public int windowReads;public Runnable beforeWindowRead;
public AccessibilityNodeInfo getRootInActiveWindow(){if(activeThrows)throw new IllegalStateException("active root failed");
return active==null?null:new AccessibilityNodeInfo(active);}
public List<AccessibilityWindowInfo> getWindows(){windowReads++;if(beforeWindowRead!=null)beforeWindowRead.run();
if(windowsThrow)throw new IllegalStateException("windows unavailable");
if(windowsNull)return null;List<AccessibilityWindowInfo> result=new ArrayList<>();
IdentityHashMap<AccessibilityWindowInfo.Data,AccessibilityWindowInfo> seen=new IdentityHashMap<>();
for(AccessibilityWindowInfo.Data d:windows){if(d==null){result.add(null);continue;}
AccessibilityWindowInfo w=seen.get(d);if(w==null){w=new AccessibilityWindowInfo(d);seen.put(d,w);}result.add(w);}return result;}
}
""",
"com/artemis/helper/ControlWindowsTest.java": """
package com.artemis.helper;
import android.accessibilityservice.AccessibilityService;import android.view.accessibility.*;
import android.os.Build;import java.util.concurrent.atomic.AtomicBoolean;import org.json.*;
public final class ControlWindowsTest {
static int checks,scenarios,nextId=1;static String scenario;
static void check(boolean v,String m){checks++;if(!v)throw new AssertionError(scenario+": "+m);}
static void eq(Object a,Object e,String m){check(e.equals(a),m+" actual="+a+" expected="+e);}
static AccessibilityNodeInfo.Data node(int window,String resource){AccessibilityNodeInfo.Data d=new AccessibilityNodeInfo.Data(nextId++,window);d.resource=resource;return d;}
static AccessibilityWindowInfo.Data window(AccessibilityNodeInfo.Data root,int layer,boolean active){return new AccessibilityWindowInfo.Data(root.window,layer,active,root);}
static AccessibilityService app(){AccessibilityService s=new AccessibilityService();s.active=node(217,"empty-shell");
s.windows.add(window(s.active,0,true));return s;}
static AccessibilityNodeInfo.Data add(AccessibilityService s,int id,int layer,String resource){
AccessibilityNodeInfo.Data d=node(id,resource);s.windows.add(window(d,layer,false));return d;}
static void rect(AccessibilityNodeInfo.Data node,int l,int t,int r,int b){node.left=l;node.top=t;node.right=r;node.bottom=b;}
static void rect(AccessibilityWindowInfo.Data window,int l,int t,int r,int b){window.left=l;window.top=t;window.right=r;window.bottom=b;}
static JSONObject args(String resource){return new JSONObject().put("selector",new JSONObject().put("resource_id",resource));}
static JSONObject inspect(AccessibilityService s,String resource){return NativeControls.inspect(s,args(resource));}
static JSONObject click(AccessibilityService s,String resource){return NativeControls.act(s,args(resource).put("action","click"));}
static void code(JSONObject r,String expected){eq(r.getString("code"),expected,"receipt code");}
static void begin(String name){quiet();scenario=name;scenarios++;}
static void quiet(){check(AccessibilityNodeInfo.live==0,"all node handles recycled: "+AccessibilityNodeInfo.live);
check(AccessibilityWindowInfo.live==0,"all window handles recycled: "+AccessibilityWindowInfo.live);
check(AccessibilityNodeInfo.doubleRecycle==0,"no node double recycle");
check(AccessibilityWindowInfo.doubleRecycle==0,"no window double recycle");}
public static void main(String[] ignored){
begin("real empty active shell with inactive upper player");AccessibilityService s=app();
AccessibilityNodeInfo.Data player=add(s,216,1,"player");player.add(node(216,"tv_timeline_date")).add(node(216,"tab_play_or_pause"));
JSONObject r=inspect(s,"tv_timeline_date");code(r,"OK");eq(r.getString("execution"),"observed","read only receipt");
eq(r.getJSONObject("node").getString("resource_id"),"tv_timeline_date","upper player resolved");
r=click(s,"tab_play_or_pause");code(r,"OK");eq(r.getString("execution"),"completed","write receipt");
eq(player.children.get(1).actionsPerformed,1,"exactly one upper-window action");
code(inspect(s,"empty-shell"),"OK");
begin("active modal excludes its lower application");s=app();s.windows.clear();s.windows.add(window(s.active,3,true));
AccessibilityNodeInfo.Data lower=add(s,216,1,"behind-dialog");code(inspect(s,"behind-dialog"),"NOT_FOUND");
code(click(s,"behind-dialog"),"NOT_FOUND");eq(lower.actionsPerformed,0,"no lower-window input");code(inspect(s,"empty-shell"),"OK");
begin("other packages, system and IME never become candidate controls");s=app();
AccessibilityNodeInfo.Data other=add(s,216,1,"outside");other.pkg="other.app";
AccessibilityNodeInfo.Data system=add(s,218,2,"outside");s.windows.get(2).type=AccessibilityWindowInfo.TYPE_SYSTEM;
s.windows.get(2).rootThrows=true;AccessibilityNodeInfo.Data ime=add(s,219,3,"outside");
s.windows.get(3).type=AccessibilityWindowInfo.TYPE_INPUT_METHOD;s.windows.get(3).rootThrows=true;
code(click(s,"outside"),"NOT_FOUND");eq(other.actionsPerformed+system.actionsPerformed+ime.actionsPerformed,0,"no foreign input");
begin("different display cannot share layer ordering");s=app();add(s,216,1,"outside");s.windows.get(1).display=7;
code(inspect(s,"outside"),"NOT_FOUND");
begin("API 24 window metadata remains usable");Build.VERSION.SDK_INT=24;s=app();add(s,216,1,"old-api");
code(inspect(s,"old-api"),"OK");Build.VERSION.SDK_INT=36;
begin("matches across active and upper roots are globally ambiguous");s=app();s.active.resource="duplicate";
AccessibilityNodeInfo.Data duplicate=add(s,216,1,"duplicate");r=click(s,"duplicate");code(r,"AMBIGUOUS");
eq(r.getInt("candidate_count"),2,"global count");eq(r.getJSONArray("candidates").length(),2,"global candidates");
eq(s.active.actionsPerformed+duplicate.actionsPerformed,0,"ambiguity performs no action");
begin("same-layer application still participates in uniqueness");s=app();s.active.resource="same-layer";
add(s,216,0,"same-layer");code(inspect(s,"same-layer"),"AMBIGUOUS");
begin("duplicate window handles and root snapshots do not invent ambiguity");s=app();add(s,216,1,"unique");
s.windows.add(s.windows.get(1));s.windows.add(new AccessibilityWindowInfo.Data(216,1,false,s.windows.get(1).root));
s.windows.add(s.windows.get(0));code(inspect(s,"unique"),"OK");code(click(s,"unique"),"OK");
eq(s.windows.get(1).root.actionsPerformed,1,"duplicate Region metadata permits only one native action");
begin("eligible unreadable roots invalidate an otherwise unique active match");s=app();
add(s,216,1,"anything");s.windows.get(1).root=null;code(click(s,"empty-shell"),"READ_FAILED");
eq(s.active.actionsPerformed,0,"unreadable candidate never permits fake uniqueness");code(inspect(s,"absent"),"READ_FAILED");
begin("candidate getRoot exception does not become absence");s=app();add(s,216,1,"anything");
s.windows.get(1).rootThrows=true;code(inspect(s,"absent"),"READ_FAILED");
begin("candidate package missing or unreadable fails closed");s=app();AccessibilityNodeInfo.Data bad=add(s,216,1,"anything");
bad.pkg=null;code(inspect(s,"absent"),"READ_FAILED");bad.pkg="";code(inspect(s,"absent"),"READ_FAILED");
bad.pkg="app";bad.packageThrows=true;code(inspect(s,"absent"),"READ_FAILED");
begin("candidate root identity changed during discovery");s=app();add(s,216,1,"anything");
s.windows.get(1).root=node(999,"anything");code(inspect(s,"absent"),"READ_FAILED");
begin("candidate child cannot be read");s=app();bad=add(s,216,1,"upper");bad.add(null);
code(inspect(s,"absent"),"READ_FAILED");bad.children.clear();bad.add(node(216,"child"));bad.childThrows=true;
code(inspect(s,"absent"),"READ_FAILED");
begin("scope resolves in upper window, target remains confined to its descendants");s=app();
AccessibilityNodeInfo.Data scope=add(s,216,1,"container");scope.add(node(216,"inside"));s.active.add(node(217,"outside"));
JSONObject scoped=args("inside").put("scope",new JSONObject().put("resource_id","container"));
code(NativeControls.inspect(s,scoped),"OK");scoped.put("selector",new JSONObject().put("resource_id","outside"));
code(NativeControls.inspect(s,scoped),"NOT_FOUND");
begin("scopes themselves must remain globally unique");s=app();s.active.resource="container";add(s,216,1,"container");
code(NativeControls.inspect(s,args("inside").put("scope",new JSONObject().put("resource_id","container"))),"AMBIGUOUS");
begin("candidate array remains capped across windows");s=app();s.active.resource="duplicate";
for(int i=0;i<12;i++)add(s,300+i,1,"duplicate");r=inspect(s,"duplicate");code(r,"AMBIGUOUS");
eq(r.getInt("candidate_count"),13,"all matches counted");eq(r.getJSONArray("candidates").length(),10,"candidate snapshot cap");
begin("node budget is shared across roots, not reset for each window");s=app();
AccessibilityNodeInfo.Data upper=add(s,216,1,"upper");for(int i=0;i<3000;i++){
s.active.add(node(217,"active-child"));upper.add(node(216,"upper-child"));}
code(inspect(s,"absent"),"LIMIT_EXCEEDED");
begin("search-limit failure cannot dispatch an early unique match");s.active.resource="unique";
code(click(s,"unique"),"LIMIT_EXCEEDED");eq(s.active.actionsPerformed,0,"bounded search proves no fake uniqueness");
begin("window enumeration has a hard upper bound with complete cleanup");s=app();
for(int i=0;i<65;i++)add(s,300+i,1,"upper");code(inspect(s,"absent"),"LIMIT_EXCEEDED");
begin("missing active root is unavailable, never absent");s=app();s.active=null;
code(inspect(s,"absent"),"UNAVAILABLE");
begin("empty, missing and unavailable metadata cannot prove uniqueness or absence");s=app();s.windows.clear();
code(inspect(s,"empty-shell"),"UNAVAILABLE");code(inspect(s,"absent"),"UNAVAILABLE");s.windowsNull=true;
code(inspect(s,"empty-shell"),"UNAVAILABLE");s.windowsNull=false;s.windowsThrow=true;code(inspect(s,"empty-shell"),"UNAVAILABLE");
begin("missing active window cannot broaden to an unrelated observed application");s=app();s.windows.clear();add(s,216,1,"upper");
code(inspect(s,"upper"),"UNAVAILABLE");code(inspect(s,"empty-shell"),"UNAVAILABLE");
begin("active metadata is not current");s=app();s.windows.get(0).active=false;add(s,216,1,"upper");
code(inspect(s,"upper"),"UNAVAILABLE");
begin("active metadata unavailable remains conservative");s=app();s.windows.get(0).metadataThrows=true;add(s,216,1,"upper");
code(inspect(s,"upper"),"UNAVAILABLE");
begin("declared candidate metadata missing after active metadata is established");s=app();s.windows.add(null);
code(inspect(s,"absent"),"READ_FAILED");
begin("single known application window permits one visible native action");s=app();
code(click(s,"empty-shell"),"OK");eq(s.active.actionsPerformed,1,"single-window input");
begin("invisible targets remain observable but cannot receive native actions");s=app();s.active.visible=false;
code(inspect(s,"empty-shell"),"OK");code(click(s,"empty-shell"),"NOT_VISIBLE");
eq(s.active.actionsPerformed,0,"invisible target never written");
begin("nonfocusable same-package higher modal blocks retained active root writes");s=app();add(s,216,1,"modal");
code(inspect(s,"empty-shell"),"OK");r=click(s,"empty-shell");code(r,"OCCLUDED");
eq(r.getString("execution"),"not_started","blocked before input");eq(s.active.actionsPerformed,0,"no modal penetration");
begin("any higher package or window type blocks full coverage");
for(int type:new int[]{1,2,3}){s=app();AccessibilityNodeInfo.Data blocker=add(s,216,1,"blocker");
blocker.pkg="other.app";s.windows.get(1).type=type;code(click(s,"empty-shell"),"OCCLUDED");
eq(s.active.actionsPerformed,0,"foreign/system/IME overlap blocked");}
begin("higher nonoverlapping status bar leaves the app target actionable");s=app();
add(s,216,1,"status-bar");s.windows.get(1).type=AccessibilityWindowInfo.TYPE_SYSTEM;s.windows.get(1).bottom=48;
code(click(s,"empty-shell"),"OK");eq(s.active.actionsPerformed,1,"nonoverlapping system bar allowed");
begin("empty higher bounds do not claim occlusion");s=app();add(s,216,1,"empty-window");
s.windows.get(1).right=0;code(click(s,"empty-shell"),"OK");
begin("higher windows on another display do not occlude this display");s=app();add(s,216,1,"other-display");
s.windows.get(1).display=7;code(click(s,"empty-shell"),"OK");
begin("own window disappearance between resolution and action is unavailable");
final AccessibilityService removed=app();removed.beforeWindowRead=()->{if(removed.windowReads==2)removed.windows.clear();};
code(click(removed,"empty-shell"),"UNAVAILABLE");eq(removed.active.actionsPerformed,0,"missing context blocks input");
begin("a new overlapping window is checked immediately before action");
final AccessibilityService newlyCovered=app();newlyCovered.beforeWindowRead=()->{
if(newlyCovered.windowReads==2)add(newlyCovered,216,1,"new-modal");};
code(click(newlyCovered,"empty-shell"),"OCCLUDED");eq(newlyCovered.active.actionsPerformed,0,"new modal blocks input");
begin("latest metadata read failure never permits input");
final AccessibilityService latestFailure=app();latestFailure.beforeWindowRead=()->{
if(latestFailure.windowReads==2)latestFailure.windowsThrow=true;};
code(click(latestFailure,"empty-shell"),"UNAVAILABLE");eq(latestFailure.active.actionsPerformed,0,"failed latest query blocks input");
begin("unknown target window bounds are unavailable");s=app();s.windows.get(0).boundsThrow=true;
code(click(s,"empty-shell"),"UNAVAILABLE");eq(s.active.actionsPerformed,0,"unknown bounds block input");
begin("unknown active display cannot prove complete resolution");s=app();s.windows.get(0).display=-1;
code(inspect(s,"empty-shell"),"UNAVAILABLE");code(inspect(s,"absent"),"UNAVAILABLE");
begin("unknown potential occluder display cannot authorize input");s=app();add(s,216,1,"system-window");
s.windows.get(1).type=AccessibilityWindowInfo.TYPE_SYSTEM;s.windows.get(1).display=-1;
code(click(s,"empty-shell"),"UNAVAILABLE");eq(s.active.actionsPerformed,0,"unknown display blocks input");
begin("empty target or own-window bounds cannot authorize input");s=app();s.active.right=s.active.left;
code(click(s,"empty-shell"),"UNAVAILABLE");s=app();s.windows.get(0).right=0;
code(click(s,"empty-shell"),"UNAVAILABLE");
begin("post-edit caret action rechecks latest windows and retains unknown partial receipt");
final AccessibilityService edit=app();edit.active.editable=true;edit.active.text="abc";
edit.beforeWindowRead=()->{if(edit.windowReads==3)add(edit,216,1,"new-overlay");};
r=NativeControls.act(edit,args("empty-shell").put("action","replace_selection").put("text","Z")
.put("selection_start",0).put("selection_end",1));code(r,"OCCLUDED");
eq(r.getString("execution"),"unknown","accepted text with blocked caret stays unknown");
eq(edit.active.text,"Zbc","accepted edit retained");eq(edit.active.actionsPerformed,1,"caret not dispatched through overlay");
begin("real code14 editor selection toolbar and handles leave editable area exposed");
s=new AccessibilityService();s.active=node(231,"activity");s.windows.add(window(s.active,0,true));
rect(s.windows.get(0),0,0,720,1600);AccessibilityNodeInfo.Data editor=node(231,"editor");
editor.editable=true;editor.text="A😀B中文";rect(editor,24,534,696,638);s.active.add(editor);
add(s,232,4,"selection-toolbar");rect(s.windows.get(1),32,446,554,542);
add(s,233,2,"selection-handle-left");rect(s.windows.get(2),1,611,76,691);
add(s,234,3,"selection-handle-right");rect(s.windows.get(3),77,611,165,691);
add(s,226,5,"status-bar");s.windows.get(4).type=AccessibilityWindowInfo.TYPE_SYSTEM;rect(s.windows.get(4),0,0,720,48);
add(s,218,1,"keyboard");s.windows.get(5).type=AccessibilityWindowInfo.TYPE_INPUT_METHOD;rect(s.windows.get(5),0,1002,720,1600);
r=NativeControls.act(s,args("editor").put("action","replace_selection").put("text"," X ")
.put("selection_start",1).put("selection_end",3));code(r,"OK");
eq(r.getString("execution"),"completed","partial occlusion permits native edit");eq(editor.text,"A X B中文","UTF-16 replacement retained");
eq(editor.actionsPerformed,2,"text and caret each accepted");eq(editor.selectionStart,4,"caret restored after inserted whitespace");
begin("partial higher overlap permits a native action on the exposed part");s=app();add(s,216,1,"partial-overlay");
rect(s.windows.get(1),0,0,60,300);code(click(s,"empty-shell"),"OK");eq(s.active.actionsPerformed,1,"exposed part remains actionable");
begin("multiple higher windows jointly covering the target deny input");s=app();add(s,216,1,"cover-left");
rect(s.windows.get(1),0,0,60,300);add(s,218,2,"cover-right");rect(s.windows.get(2),60,0,200,300);
code(click(s,"empty-shell"),"OCCLUDED");eq(s.active.actionsPerformed,0,"union coverage blocks input");
begin("one pixel gap in joint coverage remains exposed");s=app();add(s,216,1,"cover-left");
rect(s.windows.get(1),0,0,60,300);add(s,218,2,"cover-right");rect(s.windows.get(2),61,0,200,300);
code(click(s,"empty-shell"),"OK");eq(s.active.actionsPerformed,1,"integer region gap preserved");
begin("target area outside its own window cannot bypass full coverage inside");s=app();
rect(s.windows.get(0),10,100,60,200);add(s,216,1,"cover-visible-half");rect(s.windows.get(1),10,100,60,200);
code(click(s,"empty-shell"),"OCCLUDED");eq(s.active.actionsPerformed,0,"own-window clipping precedes occlusion");
begin("overlap only outside the own-window clip does not block exposed area");s=app();
rect(s.windows.get(0),10,100,60,200);add(s,216,1,"outside-own-window");rect(s.windows.get(1),60,100,110,200);
code(click(s,"empty-shell"),"OK");eq(s.active.actionsPerformed,1,"outside clip is irrelevant");
begin("a target completely outside its own window is unavailable");s=app();rect(s.windows.get(0),0,0,5,50);
code(click(s,"empty-shell"),"UNAVAILABLE");eq(s.active.actionsPerformed,0,"invalid clip cannot authorize input");
begin("screen-sized container can scroll between system and IME edge windows");s=app();
rect(s.active,0,0,720,1600);rect(s.windows.get(0),0,0,720,1600);s.active.scrollSupport=true;
add(s,216,2,"status-bar");s.windows.get(1).type=AccessibilityWindowInfo.TYPE_SYSTEM;rect(s.windows.get(1),0,0,720,48);
add(s,218,1,"keyboard");s.windows.get(2).type=AccessibilityWindowInfo.TYPE_INPUT_METHOD;rect(s.windows.get(2),0,1002,720,1600);
code(NativeControls.act(s,args("empty-shell").put("action","scroll_forward")),"OK");
eq(s.active.actionsPerformed,1,"visible middle permits native scroll");
begin("API30 own L-shaped region does not expose a target inside its bounding-box hole");Build.VERSION.SDK_INT=30;s=app();
rect(s.active,50,100,100,150);rect(s.windows.get(0),0,0,200,300);
s.windows.get(0).regions=new int[][]{{0,0,20,300},{0,0,200,50}};
code(click(s,"empty-shell"),"OCCLUDED");eq(s.active.actionsPerformed,0,"own L-shape hole blocks input");Build.VERSION.SDK_INT=36;
begin("API30 own disconnected region cannot authorize a target in its gap");s=app();
rect(s.active,50,100,90,200);rect(s.windows.get(0),0,0,140,300);
s.windows.get(0).regions=new int[][]{{0,0,40,300},{100,0,140,300}};
code(click(s,"empty-shell"),"OCCLUDED");eq(s.active.actionsPerformed,0,"own disconnected gap blocks input");
begin("API30 higher region hole leaves the target actionable despite bounding-box overlap");s=app();
add(s,216,1,"hollow-overlay");rect(s.windows.get(1),0,0,200,300);
s.windows.get(1).regions=new int[][]{{0,0,200,100},{0,200,200,300},{0,100,10,200},{110,100,200,200}};
code(click(s,"empty-shell"),"OK");eq(s.active.actionsPerformed,1,"actual overlay hole stays exposed");
begin("API30 disconnected higher regions jointly covering all target area deny input");s=app();
add(s,216,1,"alternating-a");rect(s.windows.get(1),10,100,85,200);
s.windows.get(1).regions=new int[][]{{10,100,35,200},{60,100,85,200}};
add(s,218,2,"alternating-b");rect(s.windows.get(2),35,100,110,200);
s.windows.get(2).regions=new int[][]{{35,100,60,200},{85,100,110,200}};
code(click(s,"empty-shell"),"OCCLUDED");eq(s.active.actionsPerformed,0,"union of disconnected regions blocks input");
begin("API29 uses conservative rectangular bounds without calling unsupported Region getter");
Build.VERSION.SDK_INT=29;s=app();add(s,216,1,"hollow-overlay");rect(s.windows.get(1),0,0,200,300);
s.windows.get(1).regions=new int[][]{{0,0,200,100},{0,200,200,300}};s.windows.get(1).regionThrows=true;
s.windows.get(0).regionThrows=true;code(click(s,"empty-shell"),"OCCLUDED");Build.VERSION.SDK_INT=36;
begin("API24 rectangular subtraction still permits partially exposed targets");Build.VERSION.SDK_INT=24;
s=app();add(s,216,1,"partial-overlay");rect(s.windows.get(1),0,0,60,300);
s.windows.get(0).regionThrows=true;s.windows.get(1).regionThrows=true;
code(click(s,"empty-shell"),"OK");Build.VERSION.SDK_INT=36;
begin("API30 failed Region metadata cannot silently fall back to rectangular bounds");s=app();
s.windows.get(0).regionThrows=true;code(click(s,"empty-shell"),"UNAVAILABLE");eq(s.active.actionsPerformed,0,"own Region failure blocks input");
s=app();add(s,216,1,"system-window");s.windows.get(1).type=AccessibilityWindowInfo.TYPE_SYSTEM;
s.windows.get(1).regionThrows=true;code(click(s,"empty-shell"),"UNAVAILABLE");
eq(s.active.actionsPerformed,0,"higher Region failure blocks input");
begin("post-edit disabled control prevents the caret action and preserves partial unknown");s=app();
s.active.editable=true;s.active.text="abc";s.active.disableAfterText=true;
r=NativeControls.act(s,args("empty-shell").put("action","replace_selection").put("text","Z")
.put("selection_start",0).put("selection_end",1));code(r,"DISABLED");eq(r.getString("execution"),"unknown","disabled caret is partial");
eq(s.active.text,"Zbc","accepted edit remains");eq(s.active.actionsPerformed,1,"disabled caret not dispatched");
begin("pre-cancelled discovery does not acquire input or roots");s=app();
try(ControlWindowRoots roots=ControlWindowRoots.capture(s,new AtomicBoolean(true))){
eq(roots.error,"CANCELLED","cancelled discovery");eq(roots.size(),0,"no root acquired");}
quiet();System.out.println("PASS "+checks+" checks across "+scenarios+" native-window scenarios");
}
}
""",
}
def main() -> int:
started = perf_counter()
try:
java, javac = find_tool("java"), find_tool("javac")
helper = (
Path(__file__).resolve().parents[2]
/ "packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper"
)
actual = [helper / name for name in ("NativeControls.java", "ControlWindowRoots.java")]
if not all(path.is_file() for path in actual):
raise RuntimeError("The repository native-control Java sources are missing")
print("Offline Android-window simulation against actual Java; no device input.", flush=True)
with TemporaryDirectory(prefix="artemis-control-windows-jvm-") as temporary:
workspace = Path(temporary)
classes = workspace / "classes"
classes.mkdir()
generated = []
for name, source in STUBS.items():
destination = workspace / "src" / name
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(source.lstrip("\n"), encoding="utf-8")
generated.append(str(destination))
run_command(
[
javac,
"-encoding",
"UTF-8",
"-source",
"8",
"-target",
"8",
"-Xlint:-options",
"-d",
str(classes),
*generated,
*(str(path) for path in actual),
],
timeout=60,
)
run_command([java, "-cp", str(classes), "com.artemis.helper.ControlWindowsTest"], 30)
print(f"Native-window harness completed in {perf_counter() - started:.2f}s.")
return 0
except Exception as error:
print(f"FAIL: {error}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,278 @@
"""Manual JVM simulation of TouchGestures callbacks; no Android device is used.
Run explicitly from the repository root:
uv run python tests/native_actions_fixture/touch_jvm_harness.py
Requires a JDK (javac and java) through JAVA_HOME or PATH. Uses only the Python
standard library, generates fake Android classes in a temporary directory, and
compiles the repository's actual TouchGestures.java and GestureController.java.
It checks callback ownership,
cancellation, release, and error handling. It cannot prove real Android input,
MotionEvent delivery, visual results, or physical pointer continuity.
"""
from __future__ import annotations
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
from tempfile import TemporaryDirectory
from time import perf_counter
# These minimal fakes model callback scheduling, not Android's implementation.
STUBS: dict[str, str] = {
"android/accessibilityservice/AccessibilityService.java": r"""
package android.accessibilityservice;
import android.os.Handler;import android.content.Context;import java.util.*;
public class AccessibilityService extends Context {
public static final int GLOBAL_ACTION_BACK=1,GLOBAL_ACTION_HOME=2,GLOBAL_ACTION_RECENTS=3,GLOBAL_ACTION_NOTIFICATIONS=4,GLOBAL_ACTION_QUICK_SETTINGS=5,GLOBAL_ACTION_POWER_DIALOG=6,GLOBAL_ACTION_TOGGLE_SPLIT_SCREEN=7,GLOBAL_ACTION_LOCK_SCREEN=8,GLOBAL_ACTION_TAKE_SCREENSHOT=9;
public volatile int calls; public volatile int rejectOn,throwOn,cancelOn,dropOn,throwAndCompleteOn;public volatile boolean duplicate,earlyStationary;
public volatile List<GestureDescription.StrokeDescription> held;
public volatile GestureDescription lastGesture;private volatile GestureResultCallback lastCallback;private volatile Handler lastHandler;
public boolean performGlobalAction(int action){return false;}
private void emit(int n,GestureDescription gesture,GestureResultCallback callback){if(n==cancelOn){held=null;callback.onCancelled(gesture);}else{held=gesture.strokes.get(0).continuing?gesture.strokes:null;callback.onCompleted(gesture);if(duplicate)callback.onCompleted(gesture);}}
public void completeLast(){final GestureDescription gesture=lastGesture;final GestureResultCallback callback=lastCallback;lastHandler.post(()->emit(calls,gesture,callback));}
public boolean dispatchGesture(GestureDescription gesture,GestureResultCallback callback,Handler handler){
int n=++calls;
if(n==rejectOn)return false;
if(n==throwOn)throw new RuntimeException("mock dispatch uncertainty");
for(int i=0;i<gesture.strokes.size();i++){GestureDescription.StrokeDescription s=gesture.strokes.get(i);if(s.previous!=null&&(held==null||held.get(i)!=s.previous))throw new IllegalStateException("lost continuing contact");}
lastGesture=gesture;lastCallback=callback;lastHandler=handler;
long duration=0;for(GestureDescription.StrokeDescription stroke:gesture.strokes)duration=Math.max(duration,stroke.start+stroke.duration);
GestureDescription.StrokeDescription first=gesture.strokes.get(0);if(earlyStationary&&first.continuing&&first.path.x1==first.path.x2&&first.path.y1==first.path.y2)duration=0;
if(n!=dropOn)handler.postDelayed(()->emit(n,gesture,callback),duration+3);
if(n==throwAndCompleteOn)throw new RuntimeException("mock callback after throw");
return true;
}
public static class GestureResultCallback {public void onCompleted(GestureDescription d){}public void onCancelled(GestureDescription d){}}
}
""",
"android/accessibilityservice/GestureDescription.java": r"""
package android.accessibilityservice;
import android.graphics.Path; import java.util.*;
public class GestureDescription {
public final List<StrokeDescription> strokes; private GestureDescription(List<StrokeDescription> v){strokes=v;}
public static class Builder {final List<StrokeDescription> s=new ArrayList<>(); public Builder addStroke(StrokeDescription v){s.add(v);return this;}public GestureDescription build(){return new GestureDescription(new ArrayList<>(s));}}
public static class StrokeDescription { public final Path path;public final long start,duration; public final boolean continuing;public final StrokeDescription previous;
public StrokeDescription(Path p,long start,long d){this(p,start,d,false);}
public StrokeDescription(Path p,long start,long d,boolean c){this(p,start,d,c,null);if(start<0||d<1)throw new IllegalArgumentException();}
private StrokeDescription(Path p,long start,long d,boolean c,StrokeDescription prev){path=p;this.start=start;duration=d;continuing=c;previous=prev;}
public StrokeDescription continueStroke(Path p,long start,long d,boolean c){if(!continuing||start!=0||d<1||p.x1!=path.x2||p.y1!=path.y2)throw new IllegalArgumentException("invalid continuation");return new StrokeDescription(p,start,d,c,this);}
}
}
""",
"android/graphics/Path.java": r"""
package android.graphics; public class Path { public float x1,y1,x2,y2; public void moveTo(float x,float y){x1=x2=x;y1=y2=y;}public void lineTo(float x,float y){x2=x;y2=y;} }
""",
"android/os/Build.java": r"""
package android.os; public final class Build { public static final class VERSION { public static int SDK_INT=36; } public static final class VERSION_CODES {public static final int O=26,P=28;} }
""",
"android/os/Handler.java": r"""
package android.os;
import java.util.*; import java.util.concurrent.*;
public class Handler {
public static final ScheduledThreadPoolExecutor EXEC=new ScheduledThreadPoolExecutor(1,r->{Thread t=new Thread(r,"mock-main");t.setDaemon(true);return t;});
private static final Map<Runnable,List<Future<?>>> TASKS=new ConcurrentHashMap<>();
public Handler(Looper l){} public boolean post(Runnable r){return postDelayed(r,0);}
public boolean postDelayed(Runnable r,long delay){Future<?> f=EXEC.schedule(r,Math.max(0,delay),TimeUnit.MILLISECONDS);TASKS.computeIfAbsent(r,k->new CopyOnWriteArrayList<>()).add(f);return true;}
public void removeCallbacks(Runnable r){List<Future<?>> fs=TASKS.remove(r);if(fs!=null)for(Future<?> f:fs)f.cancel(false);}
}
""",
"android/os/Looper.java": r"""
package android.os; public final class Looper { private static final Looper MAIN=new Looper(); public static Looper getMainLooper(){return MAIN;} public static Looper myLooper(){return Thread.currentThread().getName().equals("mock-main")?MAIN:null;} }
""",
"android/os/SystemClock.java": r"""
package android.os; public final class SystemClock { private static volatile long offset;public static long uptimeMillis(){return System.nanoTime()/1000000L+offset;}public static void advance(long millis){offset+=millis;} }
""",
"android/os/Bundle.java": r"""
package android.os;public class Bundle {public void putCharSequence(String key,CharSequence value){}}
""",
"android/content/Context.java": r"""
package android.content;public class Context {public static final String CLIPBOARD_SERVICE="clipboard";public Object getSystemService(String name){return null;}}
""",
"android/content/ClipData.java": r"""
package android.content;public class ClipData {public static ClipData newPlainText(CharSequence label,CharSequence text){return new ClipData();}}
""",
"android/content/ClipboardManager.java": r"""
package android.content;public class ClipboardManager {public void setPrimaryClip(ClipData clip){}}
""",
"android/util/Log.java": r"""
package android.util;public final class Log {public static int w(String tag,String message){return 0;}public static int w(String tag,String message,Throwable error){return 0;}}
""",
"android/view/accessibility/AccessibilityNodeInfo.java": r"""
package android.view.accessibility;import android.os.Bundle;public class AccessibilityNodeInfo {public static final int ACTION_SET_TEXT=2097152;public static final String ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE="text";public CharSequence getText(){return null;}public boolean isShowingHintText(){return false;}public boolean performAction(int action,Bundle args){return false;}}
""",
"com/artemis/helper/HierarchyDumper.java": r"""
package com.artemis.helper;import android.accessibilityservice.AccessibilityService;import android.view.accessibility.AccessibilityNodeInfo;public final class HierarchyDumper {public static AccessibilityNodeInfo findInputNode(AccessibilityService service){return null;}public static void safeRecycle(AccessibilityNodeInfo node){}}
""",
"com/artemis/helper/DisplayUtils.java": r"""
package com.artemis.helper;import android.accessibilityservice.AccessibilityService;public final class DisplayUtils {public static volatile int width=1080,height=2400;public static class DisplayInfo {public int width,height;DisplayInfo(int w,int h){width=w;height=h;}}public static DisplayInfo getDisplayInfo(AccessibilityService s){return new DisplayInfo(width,height);}}
""",
"com/artemis/helper/NativeControls.java": r"""
package com.artemis.helper;import android.accessibilityservice.AccessibilityService;import org.json.JSONObject;public final class NativeControls {public static volatile JSONObject result;public static JSONObject inspect(AccessibilityService s,JSONObject args){return result;}}
""",
"com/artemis/helper/TestMain.java": r"""
package com.artemis.helper;
import android.accessibilityservice.AccessibilityService;import android.os.*;import org.json.*;import java.lang.reflect.*;import java.util.concurrent.*;import java.util.concurrent.atomic.*;
public final class TestMain {
static int checks;static String scenario="initialization";static ExecutorService workers=Executors.newCachedThreadPool();
static void check(boolean v,String message){checks++;if(!v)throw new AssertionError(scenario+": "+message);}
static void eq(Object actual,Object expected,String message){check(expected.equals(actual),message+": "+actual+" != "+expected);}
static JSONObject spec(String id,int interval,int frames,int fingers)throws Exception {JSONArray fs=new JSONArray();for(int f=0;f<frames;f++){JSONArray p=new JSONArray();for(int n=0;n<fingers;n++)p.put(new JSONArray().put(100+n*100+f*10).put(200+n*100+f*20));fs.put(new JSONObject().put("at_ms",f*interval).put("points",p));}return new JSONObject().put("operation_id",id).put("screen_size",new JSONArray().put(1080).put(2400)).put("frames",fs);}
static void code(JSONObject r,String c,String execution)throws Exception{eq(r.get("code"),c,"code");eq(r.get("execution"),execution,"execution");}
static JSONObject cancel(String id)throws Exception{return TouchGestures.cancel(new JSONObject().put("operation_id",id));}
static void waitCalls(AccessibilityService service,int calls)throws Exception {long end=System.currentTimeMillis()+2000;while(service.calls<calls&&System.currentTimeMillis()<end)Thread.sleep(1);check(service.calls>=calls,"dispatch started");}
static Object active()throws Exception{Field f=TouchGestures.class.getDeclaredField("ACTIVE");f.setAccessible(true);return ((AtomicReference<?>)f.get(null)).get();}
static void waitQuiet()throws Exception{long end=System.currentTimeMillis()+2000;while(active()!=null&&System.currentTimeMillis()<end)Thread.sleep(1);check(active()==null,"active ownership released");}
static void resetUnknown()throws Exception{Field f=TouchGestures.class.getDeclaredField("ACTIVE");f.setAccessible(true);((AtomicReference<?>)f.get(null)).set(null);}
static JSONObject perform(AccessibilityService s,JSONObject a){return TouchGestures.perform(s,a);}
public static void main(String[] args)throws Exception {
try{
scenario="normal synchronized continuation";AccessibilityService s=new AccessibilityService();JSONObject r=perform(s,spec("normal",30,4,2));code(r,"OK","completed");eq(r.get("completed_segments"),3,"completed");eq(r.get("dispatched_segments"),3,"dispatched");eq(r.get("fingers"),2,"fingers");check(s.held==null,"normal final release");waitQuiet();
scenario="duplicate callbacks";s=new AccessibilityService();s.duplicate=true;r=perform(s,spec("duplicate",20,3,1));code(r,"OK","completed");eq(s.calls,2,"duplicate callback no extra dispatch");eq(r.get("completed_segments"),2,"duplicate callback no extra completion");waitQuiet();
scenario="Android early stationary callback preserves hold";s=new AccessibilityService();s.earlyStationary=true;JSONObject hold=spec("hold-timing",250,4,1);hold.optJSONArray("frames").optJSONObject(1).put("points",new JSONArray().put(new JSONArray().put(100).put(200)));long holdStarted=SystemClock.uptimeMillis();r=perform(s,hold);code(r,"OK","completed");check(SystemClock.uptimeMillis()-holdStarted>=750,"hold is not compressed by callback");eq(s.calls,3,"no extra gesture during hold");check(s.held==null,"hold final release");waitQuiet();
scenario="invalid frame validation";s=new AccessibilityService();JSONObject invalid=spec("invalid",20,2,1);invalid.put("frames",new JSONArray().put(new JSONObject().put("at_ms",0).put("points",new JSONArray().put(new JSONArray().put(0).put(0)))).put(new JSONObject().put("at_ms",0).put("points",new JSONArray().put(new JSONArray().put(1).put(1)))));r=perform(s,invalid);code(r,"INVALID_ARGUMENT","not_started");eq(s.calls,0,"invalid untouched");
scenario="display-size mismatch";DisplayUtils.width=800;r=perform(s,spec("size",20,2,1));code(r,"DISPLAY_CHANGED","not_started");eq(s.calls,0,"size untouched");DisplayUtils.width=1080;
scenario="unsupported Android API";Build.VERSION.SDK_INT=24;r=perform(s,spec("api",20,2,1));code(r,"UNSUPPORTED","not_started");Build.VERSION.SDK_INT=36;
scenario="ambiguous visible guard";NativeControls.result=new JSONObject().put("success",false).put("code","AMBIGUOUS");JSONObject guarded=spec("guard",20,2,1).put("require_visible","fixture:id/touch");r=perform(s,guarded);code(r,"AMBIGUOUS","not_started");eq(s.calls,0,"guard untouched");waitQuiet();
scenario="visible guard acceptance";NativeControls.result=new JSONObject().put("success",true).put("node",new JSONObject().put("visible",true).put("enabled",true));r=perform(s,spec("guarded-ok",20,2,1).put("require_visible","fixture:id/touch"));code(r,"OK","completed");waitQuiet();
scenario="busy ownership and mid-segment cancellation";final AccessibilityService moving=new AccessibilityService();Future<JSONObject> pending=workers.submit(()->perform(moving,spec("cancel-mid",150,4,2)));waitCalls(moving,1);AccessibilityService other=new AccessibilityService();code(perform(other,spec("busy",20,2,1)),"BUSY","not_started");eq(other.calls,0,"busy untouched");code(cancel("wrong-id"),"CANCEL_RECORDED","observed");code(cancel("cancel-mid"),"CANCEL_REQUESTED","unknown");r=pending.get(2,TimeUnit.SECONDS);code(r,"CANCELLED","cancelled");eq(r.get("completed_segments"),1,"current interval completed");eq(r.get("dispatched_segments"),1,"later movement skipped");eq(moving.calls,2,"one endpoint release");check(moving.held==null,"cancel final release");waitQuiet();
scenario="queued cancellation before input";CountDownLatch block=new CountDownLatch(1),entered=new CountDownLatch(1);new Handler(Looper.getMainLooper()).post(()->{entered.countDown();try{block.await();}catch(Exception e){throw new RuntimeException(e);}});entered.await();final AccessibilityService queued=new AccessibilityService();pending=workers.submit(()->perform(queued,spec("queued",30,2,1)));long e=System.currentTimeMillis()+1000;while(active()==null&&System.currentTimeMillis()<e)Thread.sleep(1);cancel("queued");block.countDown();r=pending.get(2,TimeUnit.SECONDS);code(r,"CANCELLED","not_started");eq(queued.calls,0,"queued cancellation no input");waitQuiet();
scenario="first dispatch rejection";s=new AccessibilityService();s.rejectOn=1;r=perform(s,spec("rejected",20,2,1));code(r,"DISPATCH_REJECTED","not_started");eq(r.get("dispatched_segments"),0,"rejected receipt");waitQuiet();
scenario="Android cancellation callback";s=new AccessibilityService();s.cancelOn=1;r=perform(s,spec("os-cancel",20,3,1));code(r,"GESTURE_CANCELLED","cancelled");eq(r.get("completed_segments"),0,"OS cancelled incomplete");waitQuiet();
scenario="continuation rejection and endpoint release";s=new AccessibilityService();s.rejectOn=2;r=perform(s,spec("reject-continuation",20,3,2));code(r,"DISPATCH_REJECTED","cancelled");eq(s.calls,3,"rejection release once");eq(r.get("completed_segments"),1,"rejected no fake completion");check(s.held==null,"rejection release confirmed");waitQuiet();
scenario="continuation exception and endpoint release";s=new AccessibilityService();s.throwOn=2;r=perform(s,spec("throw-continuation",20,3,2));code(r,"DISPATCH_ERROR","unknown");eq(s.calls,3,"throw release once");check(s.held==null,"throw release confirmed");waitQuiet();
scenario="late callback after dispatch exception";s=new AccessibilityService();s.throwAndCompleteOn=1;r=perform(s,spec("late-callback",60,3,1));code(r,"DISPATCH_ERROR","unknown");check(active()!=null,"unknown keeps active while in flight");code(perform(new AccessibilityService(),spec("still-busy",20,2,1)),"BUSY","not_started");waitQuiet();check(s.held==null,"late callback release confirmed");eq(s.calls,2,"no movement retry after throw");
scenario="failed release retains ownership";final AccessibilityService releaseReject=new AccessibilityService();releaseReject.rejectOn=2;pending=workers.submit(()->perform(releaseReject,spec("release-reject",80,3,1)));waitCalls(releaseReject,1);cancel("release-reject");r=pending.get(2,TimeUnit.SECONDS);code(r,"RELEASE_REJECTED","unknown");check(active()!=null,"release failure retains active");code(perform(new AccessibilityService(),spec("blocked",20,2,1)),"BUSY","not_started");eq(releaseReject.calls,2,"release no retry");resetUnknown();
scenario="missing callback bounded timeout";s=new AccessibilityService();s.dropOn=1;long before=SystemClock.uptimeMillis();r=perform(s,spec("timeout",15,2,1));code(r,"TIMEOUT","unknown");long elapsed=SystemClock.uptimeMillis()-before;check(elapsed<5200,"bounded timeout "+elapsed);check(active()!=null,"callback timeout retains active");eq(s.calls,1,"callback timeout no retry");resetUnknown();
int baseline=checks;if(baseline<85)throw new AssertionError("Existing baseline checks were lost: "+baseline);
OwnershipCases.run();
System.out.println("BASELINE "+baseline+" existing checks; "+(checks-baseline)+" additional ownership checks");
System.out.println("PASS "+checks+" deterministic checks");
}finally{workers.shutdownNow();Handler.EXEC.shutdownNow();}
}
}
""",
"org/json/JSONArray.java": r"""
package org.json;import java.util.*;public class JSONArray {private final List<Object> v=new ArrayList<>();public JSONArray put(Object x){v.add(x);return this;}public int length(){return v.size();}public Object get(int i)throws Exception{return v.get(i);}public JSONObject optJSONObject(int i){Object x=v.get(i);return x instanceof JSONObject?(JSONObject)x:null;}public JSONArray optJSONArray(int i){Object x=v.get(i);return x instanceof JSONArray?(JSONArray)x:null;}public String toString(){return v.toString();}}
""",
"org/json/JSONObject.java": r"""
package org.json;import java.util.*;
public class JSONObject {
public static final Object NULL=new Object(){public String toString(){return "null";}};private final Map<String,Object> m=new LinkedHashMap<>();
public JSONObject put(String k,Object v)throws Exception{m.put(k,v==null?NULL:v);return this;}public boolean has(String k){return m.containsKey(k);}public Object opt(String k){return m.get(k);}public Object get(String k)throws Exception{if(!m.containsKey(k))throw new Exception(k);return m.get(k);}
public JSONObject optJSONObject(String k){Object v=m.get(k);return v instanceof JSONObject?(JSONObject)v:null;}public JSONArray optJSONArray(String k){Object v=m.get(k);return v instanceof JSONArray?(JSONArray)v:null;}
public boolean optBoolean(String k){return optBoolean(k,false);}public boolean optBoolean(String k,boolean d){Object v=m.get(k);return v instanceof Boolean?(Boolean)v:d;}
public String optString(String k,String d){Object v=m.get(k);return v instanceof String?(String)v:d;}public String toString(){return m.toString();}
}
""",
}
def find_tool(name: str) -> str:
java_home = os.environ.get("JAVA_HOME")
if java_home:
for suffix in (".exe", ""):
candidate = Path(java_home) / "bin" / f"{name}{suffix}"
if candidate.is_file():
return str(candidate)
resolved = shutil.which(name)
if resolved:
return resolved
raise RuntimeError(f"{name} was not found; set JAVA_HOME to a JDK or add it to PATH")
def run_command(arguments: list[str], timeout: int) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
arguments,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
check=False,
)
if result.stdout:
print(result.stdout, end="" if result.stdout.endswith("\n") else "\n")
if result.stderr:
print(result.stderr, end="" if result.stderr.endswith("\n") else "\n", file=sys.stderr)
if result.returncode:
raise RuntimeError(f"{Path(arguments[0]).name} exited with status {result.returncode}")
return result
def main() -> int:
started = perf_counter()
try:
javac = find_tool("javac")
java = find_tool("java")
repository = Path(__file__).resolve().parents[2]
actual_source = (
repository
/ "packages"
/ "artemis-accessibility-helper"
/ "app/src/main/java/com/artemis/helper/TouchGestures.java"
)
if not actual_source.is_file():
raise RuntimeError("The repository TouchGestures.java source is missing")
legacy_source = actual_source.with_name("GestureController.java")
ownership_cases = Path(__file__).with_name("OwnershipCases.java")
if not legacy_source.is_file() or not ownership_cases.is_file():
raise RuntimeError(
"The real GestureController.java or ownership regression cases are missing"
)
print(
"JVM simulation with fake Android callbacks; no device input is exercised.", flush=True
)
with TemporaryDirectory(prefix="artemis-touch-jvm-") as temporary:
workspace = Path(temporary)
sources = workspace / "src"
classes = workspace / "classes"
classes.mkdir()
generated: list[str] = []
for name, source in STUBS.items():
destination = sources / name
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(source.lstrip("\n"), encoding="utf-8")
generated.append(str(destination))
run_command(
[
javac,
"-J-Duser.language=en",
"-encoding",
"UTF-8",
"-source",
"8",
"-target",
"8",
"-Xlint:-options",
"-d",
str(classes),
*generated,
str(actual_source),
str(legacy_source),
str(ownership_cases),
],
timeout=45,
)
result = run_command(
[java, "-Duser.language=en", "-cp", str(classes), "com.artemis.helper.TestMain"],
timeout=45,
)
if not re.search(
r"^PASS [1-9]\d* deterministic checks$", result.stdout, flags=re.MULTILINE
):
raise RuntimeError("The JVM process returned without a deterministic check count")
print(f"Elapsed {perf_counter() - started:.2f}s (compile + JVM simulation)")
return 0
except (OSError, RuntimeError, subprocess.TimeoutExpired) as error:
print(f"FAIL: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,560 @@
"""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))
@@ -0,0 +1,445 @@
"""Explicit device acceptance for the already-explored isolated native fixture.
Run after building/installing MainActivity and exploring it through ARTEMIS:
uv run python tests/native_actions_fixture/verify_device.py --serial SERIAL --output DIR
Uses the existing device lease, driver and actuator. Never resets app data or
stops another app. Artifacts contain fixture state and actual native receipts.
"""
import argparse
import asyncio
import base64
from io import BytesIO
import json
import math
from pathlib import Path
import subprocess
import time
from types import SimpleNamespace
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 DeviceExecutionLock
async def verify(serial: str, output: Path, repeats: int) -> None:
output.mkdir(parents=True, exist_ok=True)
lease = DeviceExecutionLock(serial, description="Native fixture acceptance")
lease.acquire(blocking=False)
client = AccessibilityClient(serial)
records = []
checks = []
started = time.monotonic()
def check(condition, label):
checks.append({"check": label, "passed": bool(condition)})
if not condition:
raise AssertionError(label)
def state():
result = subprocess.run(
adb_command(
[
"-s",
serial,
"shell",
"run-as",
"com.artemis.fixture",
"cat",
"files/native_actions_state.json",
]
),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
timeout=10,
)
return json.loads(result.stdout)
try:
await asyncio.to_thread(client.connect)
session = client.session
check(session is not None and session.protocol_version >= 3, "helper protocol 3")
# Recreate only this isolated test Activity, so counters are reproducible.
for command in (
["shell", "am", "force-stop", "com.artemis.fixture"],
["shell", "am", "start", "-W", "-n", "com.artemis.fixture/.MainActivity"],
):
subprocess.run(
adb_command(["-s", serial, *command]), capture_output=True, check=True, timeout=15
)
screen = await asyncio.to_thread(client.get_screen_data)
image = Image.open(BytesIO(base64.b64decode(screen.base64)))
width, height = image.size
driver = AndroidAdbDriver(serial, AdbClient(), client, width, height)
ctx = SimpleNamespace(
device=SimpleNamespace(device_width=width, device_height=height), data_engine=None
)
actuator = AdbActuator(
ctx, SimpleNamespace(driver=driver, native_request=driver.native_request)
)
async def call(name, *, expect=True, **args):
result = await getattr(actuator, name)(**args)
records.append({"action": name, "args": args, "result": result.model_dump(mode="json")})
check(result.ok is expect, f"{name} expected success={expect}: {result.message}")
return result.metadata
async def read(desc):
return (await call("read_control", selector={"content_desc": desc}))["node"]
async def control(desc, action, **args):
# Focus/selection can asynchronously pan the window for the IME.
# Observe a settled target instead of treating a safe stale-token
# rejection during that animation as successful input or retrying it.
async def settled_editor():
await call(
"wait_until",
selector={"content_desc": desc},
state="stable",
timeout_ms=3000,
stable_ms=500,
poll_interval_ms=100,
)
if desc == "fixture.editor":
await settled_editor()
receipt = await call(
"control_action", selector={"content_desc": desc}, action=action, **args
)
if desc == "fixture.editor":
# Selection handles/toolbars can disappear after a successful
# write. Wait through incomplete window reads; never replay it.
await settled_editor()
return receipt
await read("fixture.title")
original = state()
records.append(
{
"initial_fixture_state": original,
"helper_version": session.version_name,
"helper_code": session.version_code,
}
)
ambiguous = await call(
"read_control", expect=False, selector={"class_name": "android.widget.TextView"}
)
check(ambiguous["code"] == "AMBIGUOUS", "ambiguous selector rejected")
missing = await call(
"wait_until",
selector={"content_desc": "fixture.missing"},
state="absent",
timeout_ms=100,
)
check(missing["success"], "short absent wait works")
timeout = await call(
"wait_until",
expect=False,
selector={"content_desc": "fixture.missing"},
state="visible",
timeout_ms=100,
)
check(timeout["code"] == "TIMEOUT", "missing visible times out")
unsupported = await call(
"control_action",
expect=False,
selector={"content_desc": "fixture.click"},
action="set_progress",
value=50,
)
check(unsupported["execution"] == "not_started", "unsupported node action did not start")
for iteration in range(repeats):
print(
f"fixture iteration {iteration + 1}/{repeats}: native controls/text/wait",
flush=True,
)
value = [73, 21, 88, 49, 65][iteration % 5]
node = await read("fixture.range")
check(
node["range"]["min"] == 0 and node["range"]["max"] == 100, "range endpoints exact"
)
await control(
"fixture.range", "set_progress", value=value, expected_token=node["token"]
)
await call(
"wait_until",
selector={"content_desc": "fixture.range"},
state="value",
expected_value=value,
timeout_ms=1500,
)
check(state()["progress"] == value, "range value persisted in app")
before = state()
await control("fixture.click", "click")
await control("fixture.long_click", "long_click")
after = state()
check(after["click_count"] == before["click_count"] + 1, "native click delivered once")
check(
after["long_click_count"] == before["long_click_count"] + 1,
"native long click delivered once",
)
await control("fixture.editor", "set_text", text="A😀B中文")
bad = await call(
"control_action",
expect=False,
selector={"content_desc": "fixture.editor"},
action="set_selection",
selection_start=2,
selection_end=3,
)
check(
bad["execution"] == "not_started" and state()["text"] == "A😀B中文",
"surrogate split rejected before edit",
)
await control("fixture.editor", "set_selection", selection_start=1, selection_end=3)
selected = await read("fixture.editor")
check(
(selected["selection_start"], selected["selection_end"]) == (1, 3),
"UTF16 emoji selection exact",
)
await control("fixture.editor", "replace_selection", text=" X ")
edited = await read("fixture.editor")
check(
edited["text"] == "A X B中文"
and edited["selection_start"] == edited["selection_end"] == 4,
"replace only selection and place caret",
)
stale = await call(
"control_action",
expect=False,
selector={"content_desc": "fixture.editor"},
action="set_text",
text="wrong",
expected_token=selected["token"],
)
check(
stale["code"] == "STALE" and state()["text"] == "A X B中文",
"stale token cannot edit changed node",
)
await control("fixture.editor", "set_text", text=" 保留空白 😀 ")
check(
(await read("fixture.editor"))["text"] == " 保留空白 😀 ",
"native set text preserves whitespace",
)
editor_node = await read("fixture.editor")
if not editor_node["focused"]:
await control("fixture.editor", "focus")
ime_before = state()["ime_enter_count"]
await control("fixture.editor", "ime_enter")
check(state()["ime_enter_count"] == ime_before + 1, "IME submit delivered once")
ime = subprocess.run(
adb_command(["-s", serial, "shell", "dumpsys", "input_method"]),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=True,
timeout=10,
)
if "mInputShown=true" in ime.stdout:
check(await asyncio.to_thread(client.press_key, "back"), "dismiss visible IME")
deadline = time.monotonic() + 2
while True:
ime = subprocess.run(
adb_command(["-s", serial, "shell", "dumpsys", "input_method"]),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=True,
timeout=10,
)
if "mInputShown=false" in ime.stdout or time.monotonic() >= deadline:
break
await asyncio.sleep(0.05)
check("mInputShown=false" in ime.stdout, "IME no longer covers touch surface")
await call(
"wait_until",
selector={"content_desc": "fixture.touch"},
state="stable",
stable_ms=300,
timeout_ms=1500,
poll_interval_ms=100,
)
before = state()
await control("fixture.delayed", "click")
waiting = await read("fixture.status")
expected_version = (
int(before["status"].rsplit(":", 1)[-1]) + 1
if before["status"].startswith("status:changed:")
else iteration + 1
)
# Each delayed callback increments its own counter once per iteration.
await call(
"wait_until",
selector={"content_desc": "fixture.status"},
state="text",
expected_text=f"status:changed:{expected_version}",
timeout_ms=2500,
poll_interval_ms=100,
)
check(
waiting["text"] in {"status:waiting", f"status:changed:{expected_version}"},
"condition read uses app text",
)
await call(
"wait_until",
selector={"content_desc": "fixture.status"},
state="stable",
stable_ms=150,
timeout_ms=1000,
poll_interval_ms=50,
)
print(f"fixture iteration {iteration + 1}/{repeats}: synchronized touch", flush=True)
await control("fixture.touch_reset", "click")
touch = await read("fixture.touch")
x1, y1, x2, y2 = touch["bounds"]
left, right = max(0, x1) + 20, min(width - 1, x2) - 20
top, bottom = max(0, y1) + 20, min(height - 70, y2) - 20
check(right - left > 200 and bottom - top > 160, "touch fixture has room for gesture")
center = [(left + right) // 2, (top + bottom) // 2]
await call(
"pinch",
center=center,
start_span=80,
end_span=160,
duration_ms=400,
coordinate_space="pixel",
target_description="fixture touch surface",
)
touch_state = state()["touch"]
check(
touch_state["max_pointer_count"] == 2
and touch_state["down_count"] == touch_state["up_count"] == 1,
"pinch has one contact with two pointers",
)
check(
touch_state["cancel_count"] == 0 and 1.85 < touch_state["scale"] < 2.15,
"pinch ratio near two without cancellation",
)
await control("fixture.touch_reset", "click")
await call(
"pinch",
center=center,
start_span=120,
end_span=120,
rotation_degrees=60,
duration_ms=500,
coordinate_space="pixel",
target_description="fixture rotation",
)
touch_state = state()["touch"]
check(
abs(touch_state["rotation_degrees"] - 60) < 3,
"two finger rotation reaches 60 degrees",
)
check(
touch_state["down_count"] == touch_state["up_count"] == 1
and touch_state["cancel_count"] == 0,
"rotation keeps contact across segments",
)
await control("fixture.touch_reset", "click")
cx, cy = center
await call(
"touch_gesture",
frames=[
{"at_ms": 0, "points": [[cx - 60, cy]]},
{"at_ms": 250, "points": [[cx - 60, cy]]},
{"at_ms": 500, "points": [[cx, cy - 30]]},
{"at_ms": 750, "points": [[cx + 60, cy + 30]]},
],
coordinate_space="pixel",
target_description="fixture hold then path",
)
touch_state = state()["touch"]
check(
touch_state["down_count"] == touch_state["up_count"] == 1
and touch_state["move_count"] > 0
and touch_state["cancel_count"] == 0,
"multi segment hold is a single continuous contact",
)
events = touch_state["events"]
down = next(event for event in events if event["action"] == 0)
up = next(event for event in reversed(events) if event["action"] == 1)
first = down["pointers"][0]
moved = next(
event
for event in events
if event["action"] == 2
and any(abs(event["pointers"][0][axis] - first[axis]) > 1 for axis in ("x", "y"))
)
check(
moved["time_ms"] - down["time_ms"] >= 235, "APP timestamps prove initial 250ms hold"
)
check(
up["time_ms"] - down["time_ms"] >= 730,
"APP timestamps prove total 750ms path duration",
)
records.append({"iteration": iteration + 1, "fixture_state": state()})
print("fixture scoped scroll repeated twice", flush=True)
for _ in range(2):
await call(
"scroll_to",
container={"content_desc": "fixture.scroll"},
target={"content_desc": "fixture.bottom"},
max_scrolls=15,
timeout_ms=20000,
settle_ms=200,
)
check((await read("fixture.bottom"))["visible"], "scoped scroll found bottom")
await call(
"scroll_to",
container={"content_desc": "fixture.scroll"},
target={"content_desc": "fixture.title"},
direction="backward",
max_scrolls=15,
timeout_ms=20000,
settle_ms=200,
)
check((await read("fixture.title"))["visible"], "scoped scroll restored top")
await control("fixture.editor", "set_text", text=original["text"])
await control("fixture.range", "set_progress", value=original["progress"])
final_screen = await asyncio.to_thread(client.get_screen_data)
(output / "fixture-final.jpg").write_bytes(base64.b64decode(final_screen.base64))
print(f"PASS {len(checks)} checks / {repeats} repeated fixture iterations", flush=True)
finally:
if client.session is not None:
records.append({"final_fixture_state": state()})
(output / "result.json").write_text(
json.dumps(
{
"checks": checks,
"records": records,
"elapsed_seconds": time.monotonic() - started,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
client.disconnect()
lease.release()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--serial", required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--repeats", type=int, choices=range(1, 11), default=5)
options = parser.parse_args()
asyncio.run(verify(options.serial, options.output, options.repeats))
@@ -27,6 +27,27 @@ async def test_operator_shell_tools():
"swipe": {"gesture": "up"},
"drag": {"start": [100, 200], "end": [100, 220], "target_description": "timeline"},
"action_sequence": {"steps": [{"action": "wait", "time_in_ms": 50}]},
"pinch": {
"center": [500, 500],
"start_span": 100,
"end_span": 200,
"target_description": "canvas",
},
"touch_gesture": {
"frames": [
{"at_ms": 0, "points": [[400, 500]]},
{"at_ms": 500, "points": [[600, 500]]},
],
"target_description": "canvas path",
},
"read_control": {"selector": {"content_desc": "slider"}},
"control_action": {
"selector": {"content_desc": "slider"},
"action": "set_progress",
"value": 50,
},
"wait_until": {"selector": {"content_desc": "status"}, "state": "visible"},
"scroll_to": {"container": {"content_desc": "list"}, "target": {"text": "target"}},
"press_key": {"key": "ENTER"},
"manage_app": {"action": "launch", "app_name": "Settings"},
"wait_for_delay": {"time_in_ms": 1000},
@@ -0,0 +1,103 @@
"""Guard the prompt evidence contract; no model or device is invoked here."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from artemis.agents.checker.checker import (
CheckReport,
assemble_checker_prompt_segments,
run_final_check,
)
from artemis.context import ArtemisContext, ExecutionSetup
@pytest.mark.parametrize("entry", ["checkpoint", "final"])
def test_bound_persistent_readout_can_supply_precision_beyond_pixel_ticks(entry):
prompt = assemble_checker_prompt_segments(entry, [], probe_tool_registered=False)
# Native origin alone is insufficient: the rule must bind the displayed value
# to the selected state and require stability plus the visible pointer check.
for requirement in (
"标签、节点角色/层级",
"目标控件当前所选值或固定指针所选时间",
"不能仅因原生节点含有数值或执行者称其可信",
"在被验收状态中持续显示",
"引用已停稳或暂停的证据",
"与同一控件的指针及可辨刻度一致",
"结合读数精度与用户容差",
"粗刻度无法独立区分秒数,不等于",
"无需再由粗像素刻度独立插值证明同一精确值",
):
assert requirement in prompt
assert "拖动浮层或原生时间读数与固定指针刻度冲突时" not in prompt
@pytest.mark.parametrize("entry", ["checkpoint", "final"])
def test_native_origin_does_not_promote_unbound_or_conflicting_evidence(entry):
prompt = assemble_checker_prompt_segments(entry, [], probe_tool_registered=False)
for excluded_source in ("事件卡片时间", "视频画面水印", "录制起止时间", "拖动/松手浮层"):
assert excluded_source in prompt
assert "即使它们也能通过原生节点读取,仍须先证明上述绑定与持久性" in prompt
assert "可辨精度上存在实际冲突" in prompt
assert "读数仍在变化、绑定不明或无法确认已停稳/暂停" in prompt
assert "应判证据不足(inconclusive)" in prompt
assert "不得挑选有利读数或忽略矛盾" in prompt
assert "可信读数明确超出用户容差时仍按通用规则判定" in prompt
@pytest.mark.parametrize("entry", ["checkpoint", "final"])
def test_pixel_fallback_remains_quantitative_and_requires_post_action_evidence(entry):
prompt = assemble_checker_prompt_segments(entry, [], probe_tool_registered=False)
fallback = prompt.split("没有可信持久读数时,", 1)[1]
assert "动作后已停稳截图的两处真实数值刻度与固定指针位置作线性插值" in fallback
assert "单位换算、估读不确定性及目标误差" in fallback
assert "目标误差加估读不确定性不超过用户容差" in fallback
assert "刻度非线性或无法确定已停稳,仍应判证据不足" in fallback
assert "操作前的校准计划及回执误差不能替代动作后测量" in fallback
@pytest.mark.asyncio
async def test_final_review_receives_live_readout_and_rules_separately_from_report_claims():
ctx = MagicMock(spec=ArtemisContext)
ctx.execution_setup = ExecutionSetup()
ctx.data_engine = MagicMock()
ctx.data_engine.base_dir = "unused"
ctx.data_engine.get_agent_friendly_steps.return_value = []
ctx.data_engine.get_step_number.return_value = 7
elements = "[1] 当前所选值: 23.5; [2] 已暂停"
review = AsyncMock(return_value=CheckReport(verdicts=[]))
with (
patch("artemis.agents.checker.checker._run_check_loop", new=review),
patch(
"artemis.agents.checker.checker._capture_final_screen",
new=AsyncMock(return_value=("captured-image", elements)),
),
):
await run_final_check(
ctx,
goal="核对当前所选值及容差",
plan_text="",
ledger=[],
check_items=[],
execution_report="执行者声称当前所选值为 88",
)
review.assert_awaited_once()
messages = review.await_args.args[1]
assert "无需再由粗像素刻度独立插值证明同一精确值" in messages[0].content
assert "不得挑选有利读数或忽略矛盾" in messages[0].content
content = messages[1].content
text = content[0]["text"]
assert "Executor's Final Report (claims to verify, not independent evidence)" in text
assert "执行者声称当前所选值为 88" in text
assert f"# Final Screen Elements\n{elements}" in text
assert any(
block.get("type") == "image_url"
and block["image_url"]["url"] == "data:image/jpeg;base64,captured-image"
for block in content
)
+194
View File
@@ -0,0 +1,194 @@
"""Actual action-result/history/transcript rendering must keep native evidence."""
from copy import deepcopy
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
import pytest
from artemis.agents.validator.action_execution import exec_action
from artemis.mcp.action_types import ActionCode, ActionResult
from artemis.memory.transcript import EXECUTION_RESULT_MARKER, TranscriptLedger
from artemis.utils.task_tree import format_result_clean
NATIVE_ACTIONS = (
"pinch",
"touch_gesture",
"read_control",
"control_action",
"wait_until",
"scroll_to",
)
TOKEN = "a" * 64
SELECTOR = {"resource_id": "fixture:id/editor"}
def action_arguments(action):
drag = {
"start": [191, 501],
"end": [192, 532],
"coordinate_space": "pixel",
"target_description": "observed canvas",
}
return {
"pinch": {
"center": [200, 400],
"start_span": 100,
"end_span": 200,
"coordinate_space": "pixel",
"target_description": "observed canvas",
},
"touch_gesture": {
"frames": [
{"at_ms": 0, "points": [[191, 501]]},
{"at_ms": 400, "points": [[192, 532]]},
],
"coordinate_space": "pixel",
"target_description": "observed canvas",
},
"read_control": {"selector": SELECTOR},
"control_action": {
"selector": SELECTOR,
"action": "set_selection",
"selection_start": 3,
"selection_end": 5,
},
"wait_until": {"selector": SELECTOR, "state": "value", "expected_value": 37.5},
"scroll_to": {"container": {"resource_id": "fixture:id/list"}, "target": SELECTOR},
"drag": drag,
"action_sequence": {"steps": [{"action": "drag", **drag}]},
}[action]
def control_observation():
return {
"resource_id": "fixture:id/editor",
"text": " A😀B \n",
"selection_start": 3,
"selection_end": 5,
"range": {"min": 0, "max": 100, "current": 37.5, "type": 1},
"token": TOKEN,
"content_signature": "b" * 64,
"editable": True,
"password": False,
}
def decode_receipt(text, label):
marker = f"{label} receipt: "
assert marker in text
return json.loads(text.split(marker, 1)[1])
async def render_result_pipeline(action, metadata, failed):
message = (
"Native reply is uncertain; observe before continuing." if failed else "Input accepted."
)
result = (
ActionResult.failure(action, message, code=ActionCode.TIMEOUT, metadata=metadata)
if failed
else ActionResult.success(action, message, metadata=metadata)
)
session = SimpleNamespace(call=AsyncMock(return_value=result))
context = SimpleNamespace(
data_engine=None, device=SimpleNamespace(device_width=400, device_height=800)
)
args = action_arguments(action)
item = {"action": action, "args": args}
ok, error = await exec_action(context, session, item)
assert ok is not failed
session.call.assert_awaited_once_with(action, args)
assert item["result"] == result.agent_message
report = {
"status": "failed" if failed else "dispatched",
"execution": [{**item, "attempts": [f"Error: {error}" if failed else "Dispatched"]}],
}
history = format_result_clean(report)
assert isinstance(history, str)
ledger = TranscriptLedger(clock=lambda: 0.0)
ledger.stage_turn(
[
HumanMessage(content="Observed native controls"),
AIMessage(content="", tool_calls=[{"name": action, "args": {}, "id": "native-call"}]),
ToolMessage(content="Action Recorded", tool_call_id="native-call"),
]
)
ledger.commit_staged(step_key="native-step", validator_result=report)
rendered = ledger.render([HumanMessage(content="Next observation")])
execution_results = [
message.content[0]["text"]
for message in rendered
if isinstance(message, HumanMessage)
and isinstance(message.content, list)
and message.content
and EXECUTION_RESULT_MARKER in message.content[0].get("text", "")
]
assert len(execution_results) == 1
transcript = execution_results[0]
assert f"Status: {report['status']}" in transcript
if failed:
assert "Error:" in history and "Error:" in transcript
return result.agent_message, history, transcript
@pytest.mark.asyncio
@pytest.mark.parametrize("action", NATIVE_ACTIONS)
@pytest.mark.parametrize("failed", [False, True])
async def test_every_native_action_keeps_readings_selection_range_and_token_in_actual_result_rendering(
action, failed
):
node = control_observation()
read_only = action in {"read_control", "wait_until"}
execution = (
("not_started" if read_only else "unknown")
if failed
else ("observed" if read_only else "completed")
)
# The renderer must retain optional observed evidence even on gesture receipts.
metadata = {
"action": action,
"execution": execution,
"node": deepcopy(node),
"native_receipt": {
"success": not failed,
"execution": execution,
"code": "TIMEOUT" if failed else "OK",
"node": deepcopy(node),
},
"verification": "pending_observation",
}
stages = await render_result_pipeline(action, metadata, failed)
for rendered in stages:
recovered = decode_receipt(rendered, "Native")
assert recovered == metadata
assert recovered["node"]["text"] == " A😀B \n"
assert recovered["node"]["range"]["current"] == 37.5
assert (recovered["node"]["selection_start"], recovered["node"]["selection_end"]) == (3, 5)
assert recovered["node"]["token"] == TOKEN
assert recovered["native_receipt"]["success"] is not failed
@pytest.mark.asyncio
@pytest.mark.parametrize("action", ["drag", "action_sequence"])
@pytest.mark.parametrize("failed", [False, True])
async def test_existing_drag_and_sequence_geometry_and_partial_receipts_still_render(
action, failed
):
metadata = {
"execution": "partial" if failed else "completed",
"coordinate_space": "pixel",
"start": [191, 501],
"end": [192, 532],
"calibration": {"axis": "y", "target_value": 37.5},
}
if action == "action_sequence":
metadata["sequence_results"] = [
{"action": "click", "execution": "completed", "completed_taps": 1},
{"action": "drag", "execution": "unknown" if failed else "completed"},
]
for rendered in await render_result_pipeline(action, metadata, failed):
assert decode_receipt(rendered, "Gesture") == metadata
+39 -2
View File
@@ -35,6 +35,7 @@ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, Tool
from artemis.agents.operator.operator import OperatorNode
from artemis.agents.operator.prompts import load_operator_prompts
from artemis.agents.operator.prompts import (
OPERATOR_PROMPT_TOOLSET,
PLAN_HISTORY_STATIC_POINTER,
PLAN_HISTORY_TEMPLATE_SECTION,
PromptBuilder,
@@ -54,8 +55,8 @@ from artemis.memory.transcript import (
# SHA-256 snapshots of the legacy system message with the fixed inputs below.
# Update these when an intentional template change alters the rendered prompt.
GOLDEN_EMPTY_PLAN = "23c3d6b5fc3694275e7015f7bd0901bde54aae54fec7a8c68d4f8529c65ef4bc"
GOLDEN_SENTINEL_PLAN = "370c53f1d088a4120c63fbaea6d895f121c683b8d3c8cc7d61105d6e1d223128"
GOLDEN_EMPTY_PLAN = "f5ee50f4923fed4cb943cb80087d39ffffe66f259cfae4a54f27a244d7b06453"
GOLDEN_SENTINEL_PLAN = "bb725e2d096f6aa0cf1773809b2d0e672475589782b9c72348d9eaf8481230b9"
SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8")
@@ -84,6 +85,42 @@ async def test_legacy_prompt_bytes_match_chinese_prompt_golden():
assert hashlib.sha256(sentinel.encode("utf-8")).hexdigest() == GOLDEN_SENTINEL_PLAN
@pytest.mark.asyncio
@pytest.mark.parametrize("transcript", [False, True])
async def test_legacy_and_transcript_prompts_omit_all_unavailable_native_capabilities(transcript):
native_tools = {
"pinch",
"touch_gesture",
"read_control",
"control_action",
"wait_until",
"scroll_to",
}
available_tools = OPERATOR_PROMPT_TOOLSET - native_tools
ctx = SimpleNamespace(
execution_setup=None,
actuator=SimpleNamespace(capabilities=lambda: available_tools),
data_engine=None,
)
state = SimpleNamespace(initial_goal="FILTERED_FIXED_GOAL")
if transcript:
system = render_transcript_static_system(load_operator_prompts(), ctx, state)
else:
builder = PromptBuilder()
await TemplatePromptComponent()(
builder,
state,
ctx,
prompts=load_operator_prompts(),
template_name="main_template",
)
system = "".join(builder.system_parts)
assert "FILTERED_FIXED_GOAL" in system
assert "`click`" in system and "`drag`" in system
for tool in native_tools:
assert tool not in system
def test_main_template_contains_exactly_one_plan_history_section():
template = load_operator_prompts()["main_template"]
assert template.count(PLAN_HISTORY_TEMPLATE_SECTION) == 1
+39 -9
View File
@@ -38,6 +38,14 @@ OPERATOR_DEVICE_TOOLS = (
"wait_for_delay",
"long_press",
)
OPERATOR_NATIVE_TOOLS = (
"pinch",
"touch_gesture",
"read_control",
"control_action",
"wait_until",
"scroll_to",
)
def _tool_refs(text: str, tool: str) -> list[str]:
@@ -88,6 +96,7 @@ def test_full_toolset_is_the_default():
@pytest.mark.parametrize(
"tool",
OPERATOR_DEVICE_TOOLS
+ OPERATOR_NATIVE_TOOLS
+ ("video_analyzer", "search_history", "replay_steps", "get_step_screenshot"),
)
def test_removed_tool_leaves_no_reference(tool):
@@ -99,6 +108,23 @@ def test_removed_tool_leaves_no_reference(tool):
assert not _tool_refs(out, tool), f"'{tool}' still referenced in {name}"
def test_removing_all_native_capabilities_leaves_no_native_names_in_any_template():
available_tools = OPERATOR_PROMPT_TOOLSET - set(OPERATOR_NATIVE_TOOLS)
for name, template in load_operator_prompts().items():
out = apply_operator_prompt_contract(template, available_tools=available_tools)
for tool in OPERATOR_NATIVE_TOOLS:
assert tool not in out, f"'{tool}' still advertised without native capability in {name}"
assert "`click`" in out and "`drag`" in out
assert (
"Physical device actions (`click`, `action_sequence`, `drag`, `input_text`, `swipe`,"
" `press_key`, `manage_app`, `wait_for_delay`, `long_press`)" in out
)
assert (
"Turn-Ending Action (`click`, `action_sequence`, `drag`, `swipe`, `input_text`,"
" `long_press`, `press_key`, `manage_app`, or `wait_for_delay`)" in out
)
def test_removed_swipe_takes_its_teaching_along():
"""Gating removes the drag/slider teaching, not just the backticked name."""
template = load_operator_prompts()["main_template"]
@@ -131,26 +157,30 @@ def test_reduced_enums_stay_well_formed():
available_tools=OPERATOR_PROMPT_TOOLSET - {"manage_app", "wait_for_delay"},
)
assert (
"Physical device actions (`click`, `action_sequence`, `drag`, `input_text`, `swipe`, `press_key`, `long_press`)"
in out
"Physical device actions (`click`, `action_sequence`, `drag`, `pinch`, `touch_gesture`,"
" `read_control`, `control_action`, `wait_until`, `scroll_to`, `input_text`, `swipe`,"
" `press_key`, `long_press`)" in out
)
assert (
"Turn-Ending Action (`click`, `action_sequence`, `drag`, `swipe`, `input_text`, `long_press`, or `press_key`)"
in out
"Turn-Ending Action (`click`, `action_sequence`, `drag`, `pinch`, `touch_gesture`,"
" `read_control`, `control_action`, `wait_until`, `scroll_to`, `swipe`, `input_text`,"
" `long_press`, or `press_key`)" in out
)
def test_full_set_enum_slots_render_verbatim():
"""With every tool present the historical enumeration wording is reproduced."""
"""Every supported tool appears in the exact physical and turn-ending order."""
template = load_operator_prompts()["main_template"]
out = apply_operator_prompt_contract(template)
assert (
"Physical device actions (`click`, `action_sequence`, `drag`, `input_text`, `swipe`, `press_key`,"
" `manage_app`, `wait_for_delay`, `long_press`)" in out
"Physical device actions (`click`, `action_sequence`, `drag`, `pinch`, `touch_gesture`,"
" `read_control`, `control_action`, `wait_until`, `scroll_to`, `input_text`, `swipe`,"
" `press_key`, `manage_app`, `wait_for_delay`, `long_press`)" in out
)
assert (
"Turn-Ending Action (`click`, `action_sequence`, `drag`, `swipe`, `input_text`, `long_press`,"
" `press_key`, `manage_app`, or `wait_for_delay`)" in out
"Turn-Ending Action (`click`, `action_sequence`, `drag`, `pinch`, `touch_gesture`,"
" `read_control`, `control_action`, `wait_until`, `scroll_to`, `swipe`, `input_text`,"
" `long_press`, `press_key`, `manage_app`, or `wait_for_delay`)" in out
)
assert "Helper/Subagent tools (`ask_explorer`, `ask_diagnoser`, `video_analyzer`)" in out
assert "ADB/task tools (`run_adb_command`, `manage_task`, `android_file`)" in out
+153
View File
@@ -0,0 +1,153 @@
"""Native input is one-shot and remains owned through uncertain transport cleanup."""
import json
from types import SimpleNamespace
import urllib.error
from unittest.mock import Mock
import pytest
from artemis.clients import accessibility_client as module
from artemis.clients.accessibility_client import AccessibilityClient
from artemis.clients.screen_client_factory import FallbackScreenClient
from artemis.runtime.helper_manager import HelperSession
from artemis.runtime.native_protocol import input_completion_seconds
@pytest.fixture
def native(monkeypatch):
manager = Mock()
client = AccessibilityClient("test-device", manager=manager)
client._session = HelperSession(
serial="test-device",
local_port=40001,
transport_id="1",
version_code=12,
version_name="1.3.0",
owns_forward=True,
token="test-only",
protocol_version=3,
capabilities=("inspect_control", "control_action", "touch_gesture", "cancel_gesture"),
)
clock = [0.0]
sleeps = []
def sleep(seconds):
sleeps.append(seconds)
clock[0] += seconds
monkeypatch.setattr(module, "time", SimpleNamespace(monotonic=lambda: clock[0], sleep=sleep))
client._http_once = Mock(return_value=b'{"success":true,"execution":"completed","code":"OK"}')
return client, manager, clock, sleeps
def test_exactly_one_request_and_command_cannot_be_overridden(native):
client, manager, _, sleeps = native
receipt = {
"success": True,
"execution": "observed",
"code": "OK",
"node": {"selection_start": 3},
}
client._http_once.return_value = json.dumps(receipt).encode()
assert client.native_request("inspect_control", {"cmd": "swipe"}, timeout=0.02) == receipt
client._http_once.assert_called_once_with(
client.session, "/action", {"cmd": "inspect_control"}, 0.02
)
assert sleeps == []
manager.attach.assert_not_called()
@pytest.mark.parametrize(
"status,execution,attempts,repairs",
[
(400, "not_started", 1, 0),
(401, "not_started", 1, 0),
(403, "not_started", 1, 0),
(503, "unknown", 1, 0),
],
)
def test_http_rejection_never_replays_native_input(native, status, execution, attempts, repairs):
client, manager, _, sleeps = native
client._http_once.side_effect = urllib.error.HTTPError(
"http://test", status, "rejected", {}, None
)
result = client.native_request("control_action", {"action": "click"}, timeout=0.02)
assert result["execution"] == execution and not result["success"]
assert client._http_once.call_count == attempts
assert client._http_once.call_args.args[-1] >= 5
assert manager.push_token.call_count == repairs
manager.reattach.assert_not_called()
assert bool(sleeps) == (execution == "unknown")
@pytest.mark.parametrize(
"response", [b"broken", b"[]", b"{}", b'{"success":true,"execution":"unknown"}']
)
def test_uncertain_write_drains_without_retry(native, response):
client, _, clock, sleeps = native
client._http_once.return_value = response
result = client.native_request("control_action", {"action": "click"})
assert result["execution"] == "unknown"
assert sleeps == [5.0] and clock[0] == 5.0
client._http_once.assert_called_once()
def test_partial_transport_elapsed_time_counts_toward_input_drain(native):
client, _, clock, sleeps = native
def broken(*args):
clock[0] = 2
raise OSError("response lost after dispatch")
client._http_once.side_effect = broken
assert client.native_request("control_action", {})["execution"] == "unknown"
assert sleeps == [3.0] and clock[0] == 5
client._http_once.assert_called_once()
@pytest.mark.parametrize("command", ["inspect_control", "cancel_gesture"])
def test_non_input_calls_do_not_drain_on_failure(native, command):
client, _, _, sleeps = native
client._http_once.side_effect = OSError("offline")
result = client.native_request(command, {}, timeout=0.02)
assert not result["success"] and sleeps == []
client._http_once.assert_called_once()
def test_no_session_or_capability_cannot_trigger_attach_or_input(native):
client, manager, _, sleeps = native
client._session.capabilities = ()
assert client.native_request("touch_gesture", {})["code"] == "UNSUPPORTED"
client._session = None
assert client.native_capabilities == frozenset()
assert client.native_request("control_action", {})["execution"] == "not_started"
client._http_once.assert_not_called()
manager.attach.assert_not_called()
assert sleeps == []
@pytest.mark.parametrize(
"duration,budget", [(0, 5), (1000, 6), (10000, 15), (999999, 15), (None, 15)]
)
def test_touch_completion_budget_is_bounded(duration, budget):
assert input_completion_seconds("touch_gesture", {"frames": [{"at_ms": duration}]}) == budget
@pytest.mark.parametrize("backend", ["helper", "uiautomator"])
def test_native_input_never_uses_fallback_dispatch(backend):
client = object.__new__(FallbackScreenClient)
client._active_backend = backend
client._helper = Mock()
client._helper.native_capabilities = frozenset({"control_action"})
unknown = {"success": False, "execution": "unknown", "code": "TRANSPORT_ERROR"}
client._helper.native_request.return_value = unknown
client._call = Mock(side_effect=AssertionError("fallback is forbidden"))
result = client.native_request("control_action", {})
if backend == "helper":
assert result is unknown and client.native_capabilities == frozenset({"control_action"})
client._helper.native_request.assert_called_once()
else:
assert result["execution"] == "not_started" and client.native_capabilities == frozenset()
client._helper.native_request.assert_not_called()
client._call.assert_not_called()
@@ -0,0 +1,70 @@
"""Cancellation drains input threads, while read-only work cannot hold the deadline."""
import asyncio
from threading import Event
from types import SimpleNamespace
import pytest
from artemis.drivers.android.adb_driver import AndroidAdbDriver
@pytest.mark.asyncio
@pytest.mark.parametrize("command", ["control_action", "touch_gesture", "inspect_control"])
async def test_cancel_keeps_input_owned_until_transport_finishes(command):
started, release, finished, cancel_seen = Event(), Event(), Event(), Event()
def request(name, args, *, timeout):
if name == "cancel_gesture":
cancel_seen.set()
return {"success": True, "execution": "observed", "code": "CANCEL_RECORDED"}
started.set()
try:
if not release.wait(2):
raise RuntimeError("test transport was not released")
return {"success": True, "execution": "completed", "code": "OK"}
finally:
finished.set()
driver = object.__new__(AndroidAdbDriver)
driver._ui_adb_client = SimpleNamespace(native_request=request)
operation = asyncio.create_task(driver.native_request(command, {"operation_id": "test-cancel"}))
try:
assert await asyncio.to_thread(started.wait, 1)
operation.cancel()
done, _ = await asyncio.wait({operation}, timeout=0.05)
if command == "inspect_control":
assert operation in done and not finished.is_set()
else:
assert operation not in done and not finished.is_set()
operation.cancel()
done, _ = await asyncio.wait({operation}, timeout=0.02)
assert not done
assert cancel_seen.is_set() == (command == "touch_gesture")
finally:
release.set()
with pytest.raises(asyncio.CancelledError):
await operation
assert await asyncio.to_thread(finished.wait, 1)
@pytest.mark.asyncio
async def test_cancelled_input_transport_failure_does_not_replace_cancellation():
started, release = Event(), Event()
def request(*args, **kwargs):
started.set()
release.wait(2)
raise OSError("response lost")
driver = object.__new__(AndroidAdbDriver)
driver._ui_adb_client = SimpleNamespace(native_request=request)
operation = asyncio.create_task(driver.native_request("control_action", {}))
try:
assert await asyncio.to_thread(started.wait, 1)
operation.cancel()
await asyncio.sleep(0)
finally:
release.set()
with pytest.raises(asyncio.CancelledError):
await operation
+12
View File
@@ -85,6 +85,12 @@ def test_tool_declarations_match_fixture():
"SWIPE_TOOL": "swipe",
"DRAG_TOOL": "drag",
"ACTION_SEQUENCE_TOOL": "action_sequence",
"PINCH_TOOL": "pinch",
"TOUCH_GESTURE_TOOL": "touch_gesture",
"READ_CONTROL_TOOL": "read_control",
"CONTROL_ACTION_TOOL": "control_action",
"WAIT_UNTIL_TOOL": "wait_until",
"SCROLL_TO_TOOL": "scroll_to",
"PRESS_KEY_TOOL": "press_key",
"MANAGE_APP_TOOL": "manage_app",
"WAIT_FOR_DELAY_TOOL": "wait_for_delay",
@@ -125,6 +131,12 @@ def test_validator_declaration_order_is_stable():
"swipe",
"drag",
"action_sequence",
"pinch",
"touch_gesture",
"read_control",
"control_action",
"wait_until",
"scroll_to",
"press_key",
"read_note",
"list_notes",
+404
View File
@@ -0,0 +1,404 @@
"""Native action input contracts and geometry, without dispatching device input."""
from copy import deepcopy
import math
import pytest
from pydantic import ValidationError
from artemis.mcp.native_actions import (
NATIVE_REQUESTS,
ControlActionRequest,
ControlSelector,
PinchRequest,
TouchGestureRequest,
WaitUntilRequest,
plan_pinch,
plan_touch,
validate_native_action,
)
def touch(**overrides):
return {
"frames": [
{"at_ms": 0, "points": [[100, 200]]},
{"at_ms": 400, "points": [[300, 600]]},
],
"coordinate_space": "pixel",
"target_description": "observed canvas",
**overrides,
}
def pinch(**overrides):
return {
"center": [200, 400],
"start_span": 100,
"end_span": 200,
"coordinate_space": "pixel",
"target_description": "observed canvas",
**overrides,
}
VALID_REQUESTS = {
"pinch": pinch(),
"touch_gesture": touch(),
"read_control": {"selector": {"resource_id": "fixture:id/range"}},
"control_action": {
"selector": {"resource_id": "fixture:id/range"},
"action": "set_progress",
"value": 37.5,
},
"wait_until": {"selector": {"text": "Ready"}, "state": "visible"},
"scroll_to": {
"container": {"resource_id": "fixture:id/list"},
"target": {"text": "Destination"},
},
}
@pytest.mark.parametrize("name", VALID_REQUESTS)
def test_every_native_request_validates_before_dispatch_and_forbids_extra_fields(name):
args = deepcopy(VALID_REQUESTS[name])
assert validate_native_action(name, args, 400, 800).model_dump(exclude_none=True)
with pytest.raises(ValidationError):
validate_native_action(name, {**args, "coordinate_spce": "pixel"}, 400, 800)
@pytest.mark.parametrize(
"selector",
[
{},
{"bounds": [0, 0, 10, 10]},
{"text": ""},
{"text": 123},
{"resource_id": "item", "resourceId": "typo"},
{"resource_id": "item", "bounds": [0, 0, 10]},
{"resource_id": "item", "bounds": [0, 0, 10, 10, 20]},
{"resource_id": "item", "bounds": [10, 0, 10, 20]},
{"resource_id": "item", "bounds": [20, 0, 10, 20]},
{"resource_id": "item", "bounds": [0, 0, 10, 0]},
{"resource_id": "item", "bounds": [False, 0, 10, 10]},
{"resource_id": "item", "bounds": [0, 0, 10.0, 10]},
{"resource_id": "item", "bounds": [0, 0, "10", 10]},
],
)
def test_selectors_require_observed_semantics_and_strict_nonempty_bounds(selector):
with pytest.raises(ValidationError):
ControlSelector.model_validate(selector)
def test_exact_selector_strings_keep_whitespace_and_do_not_choose_one_attribute():
observed = {
"resource_id": "fixture:id/editor",
"text": " A😀B \n",
"content_desc": " description \t",
"class_name": "android.widget.EditText",
"package_name": "fixture",
"bounds": [-5, 20, 100, 60],
}
assert ControlSelector.model_validate(observed).model_dump(exclude_none=True) == observed
@pytest.mark.parametrize(
"frames",
[
[{"at_ms": 0, "points": [[1, 2]]}],
[{"at_ms": i, "points": [[1, 2]]} for i in range(33)],
[{"at_ms": 1, "points": [[1, 2]]}, {"at_ms": 2, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[1, 2]]}, {"at_ms": 0, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[1, 2]]}, {"at_ms": -1, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[1, 2]]}, {"at_ms": 10001, "points": [[2, 3]]}],
[{"at_ms": 0, "points": []}, {"at_ms": 10, "points": []}],
[{"at_ms": 0, "points": [[1, 2]] * 6}, {"at_ms": 10, "points": [[2, 3]] * 6}],
[{"at_ms": 0, "points": [[1, 2]]}, {"at_ms": 10, "points": [[2, 3], [4, 5]]}],
[{"at_ms": 0, "points": [[1]]}, {"at_ms": 10, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[1, 2, 3]]}, {"at_ms": 10, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[True, 2]]}, {"at_ms": 10, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[1.0, 2]]}, {"at_ms": 10, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [["1", 2]]}, {"at_ms": 10, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[1, 2]]}, {"at_ms": "10", "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[1, 2]]}, {"at_ms": 10.0, "points": [[2, 3]]}],
[{"at_ms": 0, "points": [[1, 2]]}, {"at_ms": True, "points": [[2, 3]]}],
[
{"at_ms": 0, "points": [[1, 2]], "pressure": 0.5},
{"at_ms": 10, "points": [[2, 3]]},
],
],
)
def test_touch_timelines_and_pointer_shapes_are_strict(frames):
with pytest.raises(ValidationError):
TouchGestureRequest.model_validate(touch(frames=frames))
def test_pixel_keyframes_preserve_holds_and_every_intermediate_segment():
frames = [
{"at_ms": 0, "points": [[191, 501], [211, 502]]},
{"at_ms": 150, "points": [[191, 501], [211, 502]]},
{"at_ms": 900, "points": [[192, 532], [212, 533]]},
]
planned = plan_touch(TouchGestureRequest.model_validate(touch(frames=frames)), 400, 800)
assert planned["frames"] == frames
assert planned["coordinate_space"] == "pixel"
assert planned["screen_size"] == [400, 800]
def test_full_supported_pointer_count_frame_count_and_duration_remain_valid():
frames = [
{
"at_ms": round(frame * 10000 / 31),
"points": [[50 + finger * 30, 100 + frame] for finger in range(5)],
}
for frame in range(32)
]
request = validate_native_action("touch_gesture", touch(frames=frames), 400, 800)
assert plan_touch(request, 400, 800)["frames"] == frames
def test_normalized_points_use_each_display_axis_and_accept_the_nominal_edges():
request = TouchGestureRequest.model_validate(
touch(
coordinate_space="normalized",
frames=[
{"at_ms": 0, "points": [[0, 0]]},
{"at_ms": 100, "points": [[500, 500]]},
{"at_ms": 200, "points": [[1000, 1000]]},
],
)
)
assert [f["points"] for f in plan_touch(request, 720, 1600)["frames"]] == [
[[0, 0]],
[[360, 800]],
[[719, 1599]],
]
@pytest.mark.parametrize(
"space,point",
[
("pixel", [-1, 0]),
("pixel", [400, 0]),
("pixel", [0, 800]),
("normalized", [-1, 500]),
("normalized", [1001, 500]),
("normalized", [500, 1001]),
],
)
def test_offscreen_paths_fail_before_any_coordinate_can_be_clamped(space, point):
args = touch(
coordinate_space=space,
frames=[{"at_ms": 0, "points": [[100, 100]]}, {"at_ms": 100, "points": [point]}],
)
with pytest.raises(ValueError, match="outside"):
validate_native_action("touch_gesture", args, 400, 800)
@pytest.mark.parametrize("dimensions", [(0, 800), (400, 0), (-1, 800)])
def test_invalid_display_dimensions_cannot_produce_a_plan(dimensions):
with pytest.raises(ValueError, match="positive"):
validate_native_action("touch_gesture", touch(), *dimensions)
@pytest.mark.parametrize(
"before,after",
[
([[100, 100], [300, 100]], [[300, 100], [100, 100]]),
([[100, 100], [300, 100]], [[200, 200], [200, 200]]),
([[100, 100], [100, 100]], [[100, 200], [300, 200]]),
],
)
def test_fingers_may_not_merge_at_frames_or_between_frames(before, after):
with pytest.raises(ValueError, match="remain distinct"):
validate_native_action(
"touch_gesture",
touch(frames=[{"at_ms": 0, "points": before}, {"at_ms": 100, "points": after}]),
400,
800,
)
def test_paths_can_cross_geometrically_when_fingers_reach_the_crossing_at_different_times():
args = touch(
frames=[
{"at_ms": 0, "points": [[0, 100], [100, 0]]},
{"at_ms": 400, "points": [[200, 100], [100, 400]]},
]
)
assert (
plan_touch(TouchGestureRequest.model_validate(args), 400, 800)["frames"] == args["frames"]
)
@pytest.mark.parametrize("dimensions", [(720, 1600), (1600, 720)])
def test_normalized_pinch_spans_use_short_edge_without_stretching_diagonal_angles(dimensions):
width, height = dimensions
request = PinchRequest.model_validate(
pinch(
center=[500, 500],
start_span=200,
end_span=400,
angle_degrees=45,
coordinate_space="normalized",
)
)
planned = plan_pinch(request, width, height)
for frame, expected_span in zip(planned["frames"], [144, 288], strict=True):
first, second = frame["points"]
assert second[0] - first[0] == second[1] - first[1]
assert math.dist(first, second) == pytest.approx(expected_span, abs=1.5)
assert [(first[i] + second[i]) / 2 for i in (0, 1)] == [width / 2, height / 2]
@pytest.mark.parametrize("rotation", [-360, -180, -90, 90, 180, 360])
def test_rotation_uses_curved_synchronized_keyframes_and_keeps_both_fingers_down(rotation):
planned = plan_pinch(
PinchRequest.model_validate(
pinch(center=[500, 500], start_span=200, end_span=200, rotation_degrees=rotation)
),
1000,
1000,
)
frames = planned["frames"]
assert len(frames) == abs(rotation) // 15 + 1
assert frames[0]["points"] == [[400, 500], [600, 500]]
assert frames[0]["at_ms"] == 0 and frames[-1]["at_ms"] == 800
assert all(a["at_ms"] < b["at_ms"] for a, b in zip(frames, frames[1:]))
assert all(len(frame["points"]) == 2 for frame in frames)
quarter_turn = frames[6]["points"][1]
assert quarter_turn == [500, 600 if rotation > 0 else 400]
for frame in frames:
assert math.dist(*frame["points"]) == pytest.approx(200, abs=1.5)
@pytest.mark.parametrize(
"overrides",
[
{"start_span": 0},
{"end_span": -1},
{"start_span": True},
{"end_span": "200"},
{"end_span": 100},
{"rotation_degrees": 361},
{"angle_degrees": float("nan")},
{"angle_degrees": float("inf")},
{"duration_ms": 0},
{"duration_ms": 10001},
{"duration_ms": 1.0},
{"duration_ms": 20, "rotation_degrees": 360},
{"center": [0, 0]},
{"coordinate_space": "screen"},
],
)
def test_invalid_pinch_geometry_and_units_fail_during_planning(overrides):
with pytest.raises(ValueError):
validate_native_action("pinch", pinch(**overrides), 400, 800)
@pytest.mark.parametrize("action", ["set_text", "replace_selection"])
@pytest.mark.parametrize("text", ["", " A😀B \n\t"])
def test_precise_text_edits_preserve_empty_input_unicode_and_whitespace(action, text):
request = ControlActionRequest.model_validate(
{"selector": {"resource_id": "editor"}, "action": action, "text": text}
)
assert request.model_dump(mode="json", exclude_none=True)["text"] == text
def test_selection_offsets_are_preserved_as_utf16_units_without_python_character_conversion():
text = "A😀B"
end_of_emoji = len(text[:2].encode("utf-16-le")) // 2
request = ControlActionRequest.model_validate(
{
"selector": {"text": text},
"action": "set_selection",
"selection_start": 1,
"selection_end": end_of_emoji,
}
)
wire = request.model_dump(mode="json", exclude_none=True)
assert (wire["selection_start"], wire["selection_end"]) == (1, 3)
# Bounds and surrogate-pair boundaries require the fresh native text, checked by Java.
assert end_of_emoji != len(text[:2])
@pytest.mark.parametrize(
"args",
[
{"action": "click", "value": 1},
{"action": "click", "text": "extra"},
{"action": "click", "selection_start": 0, "selection_end": 1},
{"action": "set_text"},
{"action": "set_text", "text": "x" * 65537},
{"action": "replace_selection"},
{"action": "set_progress"},
{"action": "set_progress", "value": True},
{"action": "set_progress", "value": "3"},
{"action": "set_progress", "value": float("nan")},
{"action": "set_progress", "value": float("inf")},
{"action": "set_selection", "selection_start": 0},
{"action": "set_selection", "selection_start": -1, "selection_end": 1},
{"action": "set_selection", "selection_start": 2, "selection_end": 1},
{"action": "set_selection", "selection_start": False, "selection_end": 1},
{"action": "set_selection", "selection_start": 0, "selection_end": 1.0},
{"action": "set_selection", "selection_start": 0, "selection_end": "1"},
{"action": "click", "expected_token": "a" * 63},
{"action": "click", "expected_token": "G" * 64},
{"action": "toggle"},
],
)
def test_control_actions_reject_coercion_wrong_arguments_and_invalid_tokens(args):
with pytest.raises(ValidationError):
ControlActionRequest.model_validate({"selector": {"resource_id": "control"}, **args})
@pytest.mark.parametrize(
"state,extra",
[
("visible", {}),
("absent", {}),
("enabled", {}),
("disabled", {}),
("checked", {}),
("unchecked", {}),
("selected", {}),
("unselected", {}),
("text", {"expected_text": " exact 😀 \n"}),
("value", {"expected_value": 0}),
],
)
def test_nonstable_waits_allow_short_deadlines_without_overriding_stability_default(state, extra):
request = WaitUntilRequest.model_validate(
{"selector": {"resource_id": "control"}, "state": state, "timeout_ms": 100, **extra}
)
assert request.timeout_ms == 100 and request.stable_ms == 300
if state == "text":
assert request.expected_text == extra["expected_text"]
def test_stability_window_must_fit_the_deadline_only_for_stable_waits():
args = {"selector": {"resource_id": "control"}, "state": "stable", "timeout_ms": 100}
with pytest.raises(ValidationError, match="stable_ms must fit"):
WaitUntilRequest.model_validate(args)
assert WaitUntilRequest.model_validate({**args, "stable_ms": 100}).stable_ms == 100
@pytest.mark.parametrize(
"name,args",
[
("wait_until", {"state": "text"}),
("wait_until", {"state": "value"}),
("wait_until", {"state": "visible", "expected_text": "wrong predicate"}),
("wait_until", {"state": "visible", "tolerance": 1}),
("wait_until", {"state": "value", "expected_value": float("inf")}),
("wait_until", {"state": "visible", "timeout_ms": 0}),
("wait_until", {"state": "visible", "poll_interval_ms": 49}),
("scroll_to", {"max_scrolls": 0}),
("scroll_to", {"max_scrolls": 21}),
("scroll_to", {"timeout_ms": 100, "settle_ms": 200}),
("scroll_to", {"direction": "diagonal"}),
],
)
def test_condition_and_scroll_requests_reject_incompatible_or_unbounded_arguments(name, args):
with pytest.raises(ValidationError):
NATIVE_REQUESTS[name].model_validate({**VALID_REQUESTS[name], **args})
+775
View File
@@ -0,0 +1,775 @@
"""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"]
)
+270
View File
@@ -0,0 +1,270 @@
"""Mixed native sequences exercise production preflight and dispatch over mocks."""
from copy import deepcopy
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from artemis.mcp.action_manifest import DEVICE_ACTIONS
from artemis.mcp.actuators import MockActuator
SELECTOR = {"resource_id": "fixture:id/editor"}
TOKEN = "a" * 64
def wait_step():
return {"action": "wait_until", "selector": SELECTOR, "state": "visible", "timeout_ms": 100}
def control_step():
return {
"action": "control_action",
"request": {"selector": SELECTOR, "action": "set_text", "text": " exact 😀 \n"},
}
def touch_step():
return {
"action": "touch_gesture",
"frames": [
{"at_ms": 0, "points": [[250, 500]]},
{"at_ms": 150, "points": [[250, 500]]},
{"at_ms": 400, "points": [[500, 750]]},
],
"coordinate_space": "normalized",
"target_description": "observed canvas",
}
def click_step():
return {"action": "click", "target": [50, 100], "target_description": "observed button"}
def observed():
return {
"success": True,
"execution": "observed",
"code": "OK",
"node": {
"token": TOKEN,
"content_signature": "b" * 64,
"visible": True,
"enabled": True,
"editable": True,
"password": False,
"text": " A😀B ",
"selection_start": 1,
"selection_end": 3,
"actions": ["set_text", "set_selection"],
},
}
@pytest.fixture(autouse=True)
def sequence_limit(monkeypatch):
monkeypatch.setattr(
"artemis.config.agent.load_agent_config",
lambda: SimpleNamespace(
pro=SimpleNamespace(execution=SimpleNamespace(max_burst_actions=10))
),
)
def actuator_with_native(capabilities=DEVICE_ACTIONS):
actuator = MockActuator(width=720, height=1600, capabilities=capabilities)
async def native(command, args, *, timeout=None):
if command == "inspect_control":
return observed()
return {
"success": True,
"execution": "completed",
"code": "OK",
"native_marker": command,
}
actuator.controller.native_request = AsyncMock(side_effect=native)
actuator.controller.tap_at = AsyncMock(return_value=SimpleNamespace(error=None))
actuator.controller.swipe_coords = AsyncMock()
return actuator
@pytest.mark.asyncio
@pytest.mark.parametrize(
"bad_member",
[
{**wait_step(), "timeout_ms": 0},
{**wait_step(), "state": "text"},
{**wait_step(), "timeout_ms": 3001},
{"action": "control_action", "request": {"selector": SELECTOR, "action": "set_progress"}},
{
"action": "control_action",
"request": {"selector": SELECTOR, "action": "set_text", "text": "x" * 1025},
},
{
"action": "control_action",
"request": {"selector": SELECTOR, "action": "click", "text": "unexpected"},
},
{
**touch_step(),
"frames": [
{"at_ms": 0, "points": [[100, 100]]},
{"at_ms": 400, "points": [[1001, 100]]},
],
},
{
**touch_step(),
"frames": [
{"at_ms": 0, "points": [[100, 100], [300, 100]]},
{"at_ms": 400, "points": [[300, 100], [100, 100]]},
],
},
{
**touch_step(),
"frames": [
{"at_ms": 0, "points": [[100, 100]]},
{"at_ms": 400, "points": [[200, 200], [300, 300]]},
],
},
],
)
async def test_all_native_parameters_and_paths_are_preflighted_before_even_the_first_click(
bad_member,
):
actuator = actuator_with_native()
result = await actuator.action_sequence(
[click_step(), wait_step(), control_step(), touch_step(), bad_member]
)
assert not result.ok and result.code.value == "INVALID_ARGS"
actuator.controller.native_request.assert_not_awaited()
actuator.controller.tap_at.assert_not_awaited()
actuator.controller.swipe_coords.assert_not_awaited()
assert actuator.action_history == []
@pytest.mark.asyncio
@pytest.mark.parametrize("missing_capability", ["wait_until", "control_action", "touch_gesture"])
async def test_every_native_member_capability_is_checked_before_any_input(missing_capability):
actuator = actuator_with_native(DEVICE_ACTIONS - {missing_capability})
result = await actuator.action_sequence(
[click_step(), wait_step(), control_step(), touch_step()]
)
assert not result.ok and result.code.value == "UNSUPPORTED"
assert result.metadata == {"execution": "not_started", "side_effect": "none"}
actuator.controller.native_request.assert_not_awaited()
actuator.controller.tap_at.assert_not_awaited()
actuator.controller.swipe_coords.assert_not_awaited()
@pytest.mark.asyncio
async def test_mixed_wait_control_and_touch_keep_native_receipts_and_exact_text_in_order():
actuator = actuator_with_native()
steps = [wait_step(), control_step(), touch_step()]
original = deepcopy(steps)
result = await actuator.action_sequence(steps)
assert result.ok and result.metadata["side_effect"] == "possible"
assert steps == original
calls = actuator.controller.native_request.await_args_list
assert [call.args[0] for call in calls] == [
"inspect_control",
"inspect_control",
"control_action",
"touch_gesture",
]
assert calls[2].args[1]["text"] == " exact 😀 \n"
assert calls[2].args[1]["expected_token"] == TOKEN
receipts = result.metadata["sequence_results"]
assert [item["action"] for item in receipts] == [
"wait_until",
"control_action",
"touch_gesture",
]
assert receipts[0]["read_only"] and receipts[0]["side_effect"] == "none"
assert receipts[0]["native_execution"] == "observed"
assert receipts[0]["node"]["selection_end"] == 3
assert receipts[1]["native_receipt"]["native_marker"] == "control_action"
assert receipts[2]["native_receipt"]["native_marker"] == "touch_gesture"
assert receipts[0]["ended_ms"] <= receipts[1]["started_ms"] <= receipts[2]["started_ms"]
actuator.controller.tap_at.assert_not_awaited()
actuator.controller.swipe_coords.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", [False, True])
async def test_readonly_native_wait_never_counts_as_an_input_side_effect(failure):
actuator = actuator_with_native()
if failure:
actuator.controller.native_request.return_value = None
actuator.controller.native_request.side_effect = lambda *args, **kwargs: {
"success": False,
"execution": "not_started",
"code": "UNAVAILABLE",
"error": "No active native tree",
}
result = await actuator.action_sequence([wait_step()])
assert result.ok is not failure
assert result.metadata["side_effect"] == "none"
receipt = result.metadata["sequence_results"][0]
assert receipt["read_only"] and receipt["side_effect"] == "none"
assert receipt["native_execution"] == ("not_started" if failure else "observed")
assert [call.args[0] for call in actuator.controller.native_request.await_args_list] == [
"inspect_control"
]
@pytest.mark.asyncio
@pytest.mark.parametrize("unknown_member", ["control_action", "touch_gesture"])
async def test_unknown_native_input_stops_every_remaining_member_and_preserves_original_receipt(
unknown_member,
):
actuator = actuator_with_native()
uncertain = {
"success": False,
"execution": "unknown",
"code": "TRANSPORT_ERROR",
"error": "Reply lost; do not replay input",
"native_marker": "unacknowledged input",
}
async def native(command, args, *, timeout=None):
return observed() if command == "inspect_control" else uncertain
actuator.controller.native_request.side_effect = native
member = control_step() if unknown_member == "control_action" else touch_step()
result = await actuator.action_sequence([wait_step(), member, click_step(), touch_step()])
assert not result.ok and result.metadata["side_effect"] == "possible"
receipts = result.metadata["sequence_results"]
assert receipts[1]["execution"] == "unknown"
assert receipts[1]["native_execution"] == "unknown"
assert receipts[1]["native_receipt"] == uncertain
assert [item["execution"] for item in receipts[2:]] == ["not_started", "not_started"]
writes = [
call.args[0]
for call in actuator.controller.native_request.await_args_list
if call.args[0] != "inspect_control"
]
assert writes == [unknown_member]
actuator.controller.tap_at.assert_not_awaited()
actuator.controller.swipe_coords.assert_not_awaited()
@pytest.mark.asyncio
async def test_normalized_touch_in_sequence_is_mapped_to_physical_pixels_exactly_once():
actuator = actuator_with_native()
result = await actuator.action_sequence([touch_step()])
assert result.ok
command, sent = actuator.controller.native_request.await_args.args
assert command == "touch_gesture" and sent["screen_size"] == [720, 1600]
expected = [
{"at_ms": 0, "points": [[180, 800]]},
{"at_ms": 150, "points": [[180, 800]]},
{"at_ms": 400, "points": [[360, 1200]]},
]
assert sent["frames"] == expected
receipt = result.metadata["sequence_results"][0]
assert receipt["plan"]["frames"] == expected
assert receipt["plan"]["coordinate_space"] == "pixel"
assert receipt["args"]["coordinate_space"] == "normalized"
assert receipt["args"]["frames"] == touch_step()["frames"]
+28
View File
@@ -25,6 +25,34 @@ def delta(arguments, *, name=None, index=0):
)
@pytest.mark.parametrize(
"name", ["pinch", "touch_gesture", "read_control", "wait_until", "scroll_to"]
)
def test_native_small_actions_stop_argument_inflation(name):
guard = ToolArgumentGuard()
guard.observe(
SimpleNamespace(
tool_call_chunks=[{"index": 0, "name": name, "args": "x" * SMALL_ACTION_ARGUMENT_LIMIT}]
)
)
with pytest.raises(ToolArgumentOverflow) as error:
guard.observe(SimpleNamespace(tool_call_chunks=[{"index": 0, "args": "x"}]))
assert error.value.details["tool"] == name
assert error.value.details["limit_chars"] == SMALL_ACTION_ARGUMENT_LIMIT
def test_standalone_native_text_allows_valid_escaped_text_but_remains_bounded():
guard = ToolArgumentGuard()
escaped = json.dumps({"text": "中" * 65536})
assert len(escaped) > SMALL_ACTION_ARGUMENT_LIMIT
guard.observe(
SimpleNamespace(tool_call_chunks=[{"index": 0, "name": "control_action", "args": escaped}])
)
with pytest.raises(ToolArgumentOverflow) as error:
guard.observe(SimpleNamespace(tool_call_chunks=[{"index": 0, "args": "x" * (512 * 1024)}]))
assert error.value.details["limit_chars"] == 512 * 1024
@pytest.fixture(autouse=True)
def isolate(monkeypatch, tmp_path):
llm._ENDPOINT_BREAKER._states.clear()
@@ -0,0 +1,209 @@
"""Hierarchy native semantics must survive both supported observation encodings."""
from copy import deepcopy
import json
import xml.etree.ElementTree as ET
import pytest
from artemis.clients.accessibility_client import normalize_helper_elements
from artemis.clients.ui_automator_client import _parse_hierarchy_xml_to_elements
from artemis.utils.native_control_state import informative_native_control, native_control_state
from artemis.utils.visualization import format_minimal_list_with_elements
RANGE = {"min": 0, "max": 100, "current": 37.5, "type": 1}
ACTIONS = ["set_progress", "set_selection", "set_text"]
def hierarchy_node(**overrides):
return {
"text": "",
"content-desc": "",
"class": "android.widget.SeekBar",
"package": "com.artemis.fixture",
"resource-id": "com.artemis.fixture:id/range",
"bounds": "[10,20][210,80]",
"enabled": True,
"editable": False,
"password": False,
"checked": False,
"checkable": False,
"selected": False,
"focused": False,
"scrollable": False,
"visible-to-user": True,
"selection_start": -1,
"selection_end": -1,
"range": deepcopy(RANGE),
"actions": list(ACTIONS),
**overrides,
}
def encode_node(node, encoding):
if encoding == "json":
return normalize_helper_elements(json.loads(json.dumps([node])))[0]
attributes = {}
for key, value in node.items():
name = key.replace("_", "-") if key in {"selection_start", "selection_end"} else key
attributes[name] = (
json.dumps(value, ensure_ascii=False)
if isinstance(value, (dict, list, bool)) or value is None
else str(value)
)
root = ET.Element("hierarchy")
ET.SubElement(root, "node", attributes)
return _parse_hierarchy_xml_to_elements(ET.tostring(root, encoding="unicode"))[0]
@pytest.mark.parametrize("encoding", ["xml", "json"])
def test_native_range_actions_flags_and_utf16_selection_map_equally_from_xml_and_json(encoding):
state = native_control_state(
encode_node(hierarchy_node(editable=True, selection_start=1, selection_end=3), encoding)
)
assert state == {
"range": RANGE,
"actions": ACTIONS,
"enabled": True,
"scrollable": False,
"editable": True,
"password": False,
"checked": False,
"checkable": False,
"selected": False,
"focused": False,
"selection_start": 1,
"selection_end": 3,
}
@pytest.mark.parametrize("encoding", ["xml", "json"])
@pytest.mark.parametrize(
"semantics",
[
{"range": RANGE, "actions": []},
{"range": None, "actions": ["set_progress"]},
{"range": None, "actions": ["click"]},
{"range": None, "actions": [], "scrollable": True},
],
)
def test_unnamed_native_controls_are_discoverable_in_the_actual_visualization(encoding, semantics):
node = encode_node(hierarchy_node(**semantics), encoding)
text, elements, labels = format_minimal_list_with_elements([node], 400, 800)
assert len(elements) == 1 and labels == ["1"]
element = elements[0]
assert element["text"] == "(native control)"
assert element["native_text"] == "" and element["content_desc"] == ""
assert element["resource_id"] == "com.artemis.fixture:id/range"
assert element["package_name"] == "com.artemis.fixture"
assert element["class"] == "android.widget.SeekBar"
assert element["center"] == [110, 50] and element["bounds"] == [10, 20, 210, 80]
assert "Control: '(native control)'" in text
assert 'package_name: "com.artemis.fixture"' in text
if semantics["range"] is not None:
assert element["range"] == RANGE and '"current": 37.5' in text
if semantics["actions"]:
assert element["actions"] == semantics["actions"]
assert "actions: " + ",".join(semantics["actions"]) in text
@pytest.mark.parametrize("encoding", ["xml", "json"])
def test_native_text_description_package_and_selection_preserve_exact_observed_data(encoding):
raw = hierarchy_node(
text=" A😀B \n\t",
**{
"content-desc": " description \n\t",
"class": "android.widget.EditText",
"package": "com.artemis.fixture.editor",
"editable": True,
"selection_start": 1,
"selection_end": 3,
},
)
node = encode_node(raw, encoding)
original = deepcopy(node)
text, elements, _ = format_minimal_list_with_elements([node], 400, 800)
element = elements[0]
assert element["native_text"] == raw["text"]
assert element["input_value"] == raw["text"]
assert element["content_desc"] == raw["content-desc"]
assert element["package_name"] == raw["package"]
assert (element["selection_start"], element["selection_end"]) == (1, 3)
assert element["range"] == RANGE and element["actions"] == ACTIONS
assert json.dumps(raw["content-desc"], ensure_ascii=False) in text
assert node == original
@pytest.mark.parametrize(
"bad_range",
[
None,
[],
"invalid json",
"{}",
{"min": 0, "max": 100},
{"min": 10, "max": 0, "current": 5},
{"min": 0, "max": 10, "current": 11},
{"min": False, "max": 10, "current": 1},
{"min": "0", "max": 10, "current": 1},
{"min": 0, "max": float("inf"), "current": 1},
{"min": 0, "max": 10, "current": float("nan")},
],
)
def test_malformed_range_does_not_hide_other_valid_native_metadata(bad_range):
assert native_control_state({"range": bad_range, "actions": ["click"], "enabled": "true"}) == {
"actions": ["click"],
"enabled": True,
}
@pytest.mark.parametrize("encoding", ["xml", "json"])
@pytest.mark.parametrize("field", ["min", "max", "current"])
def test_huge_range_integers_are_omitted_without_crashing_observation(encoding, field):
value = {**RANGE, field: 10**1000}
node = encode_node(hierarchy_node(range=value), encoding)
state = native_control_state(node)
assert "range" not in state and state["actions"] == ACTIONS
_, elements, _ = format_minimal_list_with_elements([node], 400, 800)
assert len(elements) == 1 and "range" not in elements[0]
@pytest.mark.parametrize(
"key", ["selection_start", "selection-start", "selection_end", "selection-end"]
)
@pytest.mark.parametrize("bad_offset", [True, 1.5, "1.5", "--1", "²", -2, 10**1000, "9" * 100])
def test_invalid_or_impossible_selection_offsets_are_ignored_without_raising(key, bad_offset):
state = native_control_state({key: bad_offset, "enabled": True})
assert state == {"enabled": True}
@pytest.mark.parametrize("value", [-1, 0, 3, 2**31 - 1, "-1", "0", "3", str(2**31 - 1)])
def test_supported_native_int_selection_offsets_are_kept(value):
assert native_control_state({"selection-start": value}) == {"selection_start": int(value)}
def test_encoded_metadata_is_bounded_and_rejects_nonstring_action_names():
state = native_control_state(
{
"range": " " * 8193,
"actions": ["click", None, 7, False, "x" * 65, *["set_progress"] * 40],
}
)
assert "range" not in state
assert state["actions"] == ["click", *["set_progress"] * 27]
assert "actions" not in native_control_state({"actions": " " * 8193})
@pytest.mark.parametrize("value", [None, 0, 1, "TRUE", "False", "yes", {}, []])
def test_metadata_flags_require_actual_boolean_values_or_canonical_xml_strings(value):
assert native_control_state({"enabled": value}) == {}
def test_passive_empty_layouts_do_not_become_actionable_controls():
state = native_control_state({"actions": ["focus", "show_on_screen"], "range": None})
assert not informative_native_control(state)
text, elements, labels = format_minimal_list_with_elements(
[hierarchy_node(actions=[], range=None)], 400, 800
)
assert (text, elements, labels) == ("", [], [])