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
|
@@ -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
|
|
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
|
|
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
|
|
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' } });
|