android-midscene-automation 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # 更新记录
2
+ ## v1.1.3
3
+ - [Appium]修复登录等点击操作已经触发 Activity 跳转后,UiAutomator2 仍等待点击确认、最终误报超时并占用命令队列的问题;元素点击先读取实时区域,再通过 ADB 点击中心坐标,并限制界面空闲等待时间。
4
+
5
+ ## v1.1.2
6
+ - [Appium]修复登录等点击操作已经触发 Activity 跳转后,持续动画或频繁界面事件仍让 UiAutomator2 长时间等待、最终误报点击超时的问题;限制界面空闲等待时间,避免命令队列被长期占用。
7
+
2
8
  ## v1.1.1
3
- 更新README.md
9
+ - [文档]更新 README.md。
4
10
 
5
11
  ## v1.1.0
6
12
  - [Artemis]修复自定义模型返回 `<think>` 思考标签导致 Hopper 等节点 JSON 解析失败的问题:非 Google 模型的 Pydantic 结构化输出接入统一清理、提取和校验流程,显式提供 JSON Schema,格式错误时限次要求模型纠正。
package/README.md CHANGED
@@ -1502,8 +1502,14 @@ Linux: ~/.local/share/android-midscene-automation
1502
1502
 
1503
1503
  # 项目更新记录
1504
1504
 
1505
+ ## v1.1.3
1506
+ - [Appium]修复登录等点击操作已经触发 Activity 跳转后,UiAutomator2 仍等待点击确认、最终误报超时并占用命令队列的问题;元素点击先读取实时区域,再通过 ADB 点击中心坐标,并限制界面空闲等待时间。
1507
+
1508
+ ## v1.1.2
1509
+ - [Appium]修复登录等点击操作已经触发 Activity 跳转后,持续动画或频繁界面事件仍让 UiAutomator2 长时间等待、最终误报点击超时的问题;限制界面空闲等待时间,避免命令队列被长期占用。
1510
+
1505
1511
  ## v1.1.1
1506
- 更新README.md
1512
+ - [文档]更新 README.md。
1507
1513
 
1508
1514
  ## v1.1.0
1509
1515
  - [Artemis]修复自定义模型返回 `<think>` 思考标签导致 Hopper 等节点 JSON 解析失败的问题:非 Google 模型的 Pydantic 结构化输出接入统一清理、提取和校验流程,显式提供 JSON Schema,格式错误时限次要求模型纠正。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
@@ -50,6 +50,7 @@ type AppiumValueResponse<T> = {
50
50
  };
51
51
 
52
52
  const ELEMENT_KEY = 'element-6066-11e4-a52e-4f735466cecf';
53
+ const UIAUTOMATOR_IDLE_TIMEOUT_MS = 1000;
53
54
  const configuredAppiumServerUrl = () => (process.env.APPIUM_SERVER_URL || 'http://127.0.0.1:4723').replace(/\/+$/, '');
54
55
 
55
56
  class ReplayStoppedError extends Error {
@@ -420,6 +421,9 @@ async function createSession(deviceId: string) {
420
421
  'appium:udid': deviceId,
421
422
  'appium:autoLaunch': false,
422
423
  'appium:noReset': true,
424
+ // 实时画面、动画和频繁无障碍事件可能让 UiAutomator2 长时间等不到界面空闲。
425
+ // 节点已有显式等待,因此这里只保留很短的空闲窗口,避免点击已生效后请求仍占住命令队列。
426
+ 'appium:settings': { waitForIdleTimeout: UIAUTOMATOR_IDLE_TIMEOUT_MS },
423
427
  // 托管进程由 finally 释放;外部服务保留回收期限,避免创建请求取消后遗留永久会话。
424
428
  'appium:newCommandTimeout': usesManagedAppiumServer() ? 0 : 60,
425
429
  'appium:skipDeviceInitialization': true,
@@ -532,11 +536,36 @@ async function imageCheckRegion(sessionId: string, step: AppiumRecordedStepRecor
532
536
 
533
537
  async function tapFallback(deviceId: string, step: AppiumRecordedStepRecord) {
534
538
  if (step.fallback?.strategy !== 'bounds' || !Number.isFinite(step.fallback.centerX) || !Number.isFinite(step.fallback.centerY)) {
535
- throw new Error(`${step.label} 未找到元素,且没有可用坐标兜底`);
539
+ throw new Error(`${step.label} 没有可用点击坐标`);
536
540
  }
537
541
  await adbTap(deviceId, Number(step.fallback.centerX), Number(step.fallback.centerY));
538
542
  }
539
543
 
544
+ async function tapLocatedElement(
545
+ sessionId: string,
546
+ deviceId: string,
547
+ elementId: string,
548
+ step?: AppiumRecordedStepRecord,
549
+ ) {
550
+ try {
551
+ const payload = await appiumRequest<AppiumValueResponse<VisualChangeRegion>>(`/session/${sessionId}/element/${elementId}/rect`);
552
+ const rect = payload.value;
553
+ if (rect && [rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) && rect.width > 0 && rect.height > 0) {
554
+ await adbTap(deviceId, rect.x + rect.width / 2, rect.y + rect.height / 2);
555
+ return;
556
+ }
557
+ } catch (error) {
558
+ throwIfReplayStopped(error);
559
+ // 普通点击的外层会重新定位一次,避免列表刷新后使用已失效元素的旧位置。
560
+ if (isStaleElementError(error)) throw error;
561
+ }
562
+ if (step) {
563
+ await tapFallback(deviceId, step);
564
+ return;
565
+ }
566
+ throw new Error('无法读取目标元素的实时点击区域');
567
+ }
568
+
540
569
  async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord) {
541
570
  const startedAt = Date.now();
542
571
  const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
@@ -904,10 +933,7 @@ async function runStep(
904
933
  if (step.type === 'tapIfExists') {
905
934
  const elementId = await findOptionalElement(sessionId, step);
906
935
  if (!elementId) return '未出现,已跳过';
907
- await appiumRequest(`/session/${sessionId}/element/${elementId}/click`, {
908
- method: 'POST',
909
- body: JSON.stringify({}),
910
- });
936
+ await tapLocatedElement(sessionId, deviceId, elementId, step);
911
937
  return '已出现,已点击';
912
938
  }
913
939
 
@@ -958,10 +984,7 @@ async function runStep(
958
984
  for (let attempt = 0; attempt < 2; attempt += 1) {
959
985
  try {
960
986
  const elementId = await findElement(sessionId, step);
961
- await appiumRequest(`/session/${sessionId}/element/${elementId}/click`, {
962
- method: 'POST',
963
- body: JSON.stringify({}),
964
- });
987
+ await tapLocatedElement(sessionId, deviceId, elementId, step);
965
988
  return;
966
989
  } catch (error) {
967
990
  throwIfReplayStopped(error);
@@ -1048,7 +1071,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
1048
1071
  const element = payload.value[0]!;
1049
1072
  const id = element[ELEMENT_KEY] || element.ELEMENT;
1050
1073
  if (!id) throw new Error('文字点击查询缺少元素 ID');
1051
- await appiumRequest(`/session/${sessionId}/element/${id}/click`, { method: 'POST', body: '{}' });
1074
+ await tapLocatedElement(sessionId, deviceId, id);
1052
1075
  return true;
1053
1076
  }
1054
1077
  if (Date.now() >= deadline) {
@@ -0,0 +1,86 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import childProcess from 'node:child_process';
4
+ import { syncBuiltinESMExports } from 'node:module';
5
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import type { AppiumRecordedScriptRecord } from './repository';
9
+
10
+ test('element taps use the live element center through ADB without waiting for Appium click acknowledgement', async () => {
11
+ const root = await mkdtemp(join(tmpdir(), 'element-tap-'));
12
+ const env = { ...process.env };
13
+ const original = { execFile: childProcess.execFile, fetch: globalThis.fetch };
14
+ const appiumPaths: string[] = [];
15
+ const adbCalls: string[][] = [];
16
+ let elementRequestCount = 0;
17
+ try {
18
+ const sdk = join(root, 'sdk');
19
+ await mkdir(join(sdk, 'platform-tools'), { recursive: true });
20
+ await writeFile(join(sdk, 'platform-tools', 'adb'), '');
21
+ Object.assign(process.env, {
22
+ ANDROID_MIDSCENE_DATA_ROOT: root,
23
+ ANDROID_HOME: sdk,
24
+ ANDROID_SDK_ROOT: sdk,
25
+ APPIUM_SERVER_URL: 'http://external.invalid',
26
+ });
27
+ childProcess.execFile = ((_command, args: string[], _options, callback) => {
28
+ adbCalls.push(args);
29
+ queueMicrotask(() => callback(null, '', ''));
30
+ return {};
31
+ }) as typeof childProcess.execFile;
32
+ syncBuiltinESMExports();
33
+ globalThis.fetch = async (input, init) => {
34
+ const path = new URL(String(input)).pathname;
35
+ appiumPaths.push(`${init?.method || 'GET'} ${path}`);
36
+ if (path === '/session' && init?.method === 'POST') {
37
+ return Response.json({ value: { sessionId: 'test-session' } });
38
+ }
39
+ if (path === '/session/test-session/element' && init?.method === 'POST') {
40
+ elementRequestCount += 1;
41
+ return Response.json({ value: { 'element-6066-11e4-a52e-4f735466cecf': `button-${elementRequestCount}` } });
42
+ }
43
+ if (path === '/session/test-session/element/button-2/rect') {
44
+ return Response.json({ value: { error: 'stale element reference', message: 'element changed' } }, { status: 404 });
45
+ }
46
+ if (/\/element\/button-[13]\/rect$/.test(path)) {
47
+ return Response.json({ value: { x: 10, y: 20, width: 100, height: 60 } });
48
+ }
49
+ return Response.json({ value: null });
50
+ };
51
+ const { replayAppiumScript } = await import('./appium-runner');
52
+ const script: AppiumRecordedScriptRecord = {
53
+ id: 'tap', name: 'tap', deviceId: 'fake-device', appPackage: '', appActivity: '', createdAt: '', updatedAt: '',
54
+ steps: [{
55
+ id: 'login', type: 'tap', label: '点击登录按钮',
56
+ selector: { strategy: 'id', value: 'example:id/login', unique: true },
57
+ fallback: { strategy: 'bounds', centerX: 999, centerY: 999 },
58
+ }, {
59
+ id: 'refreshing', type: 'tap', label: '点击刷新列表中的按钮',
60
+ selector: { strategy: 'id', value: 'example:id/refreshing', unique: true },
61
+ fallback: { strategy: 'bounds', centerX: 999, centerY: 999 },
62
+ }],
63
+ };
64
+ const result = await replayAppiumScript(script, 'fake-device', undefined, undefined, {
65
+ screenshotReport: false,
66
+ reportSummaryEnabled: false,
67
+ });
68
+ assert.equal(result.success, true, result.output);
69
+ assert.ok(appiumPaths.includes('GET /session/test-session/element/button-1/rect'));
70
+ assert.ok(appiumPaths.includes('GET /session/test-session/element/button-3/rect'));
71
+ assert.equal(elementRequestCount, 3, 're-locate once after a stale element rect');
72
+ assert.equal(appiumPaths.some(path => path.endsWith('/click')), false, 'must not call the blocking Appium click endpoint');
73
+ assert.deepEqual(adbCalls.filter(args => args.includes('tap')).map(args => args.slice(-4)), [
74
+ ['input', 'tap', '60', '50'],
75
+ ['input', 'tap', '60', '50'],
76
+ ]);
77
+ } finally {
78
+ childProcess.execFile = original.execFile;
79
+ globalThis.fetch = original.fetch;
80
+ syncBuiltinESMExports();
81
+ for (const key of ['ANDROID_MIDSCENE_DATA_ROOT', 'ANDROID_HOME', 'ANDROID_SDK_ROOT', 'APPIUM_SERVER_URL']) {
82
+ if (env[key] === undefined) delete process.env[key]; else process.env[key] = env[key];
83
+ }
84
+ await rm(root, { recursive: true, force: true });
85
+ }
86
+ });
@@ -47,6 +47,7 @@ test('replay survives command idle periods and cleans sessions after success, ab
47
47
  if (path === '/session' && init?.method === 'POST') {
48
48
  if (mode === 'creation-failure') return Response.json({ value: { error: 'session not created', message: 'creation failed' } }, { status: 500 });
49
49
  const capabilities = JSON.parse(String(init.body)).capabilities.alwaysMatch;
50
+ assert.deepEqual(capabilities['appium:settings'], { waitForIdleTimeout: 1000 }, 'bound UiAutomator idle waits for animated screens');
50
51
  commandTimeoutMs = (capabilities['appium:newCommandTimeout'] ?? 60) * 1000;
51
52
  lastCommandMs = elapsedMs;
52
53
  return Response.json({ value: { sessionId: 'test-session' } });