android-midscene-automation 1.1.3 → 1.1.4
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 +6 -0
- package/README.md +6 -0
- package/package.json +1 -1
- package/server/appium-recorder/appium-runner.ts +231 -78
- package/server/appium-recorder/element-tap.test.ts +171 -6
- package/server/appium-recorder/managed-cleanup.test.ts +7 -7
- package/server/appium-recorder/replay-lifecycle.test.ts +4 -4
- package/server/appium-recorder/repository.ts +39 -9
- package/server/appium-recorder/request-timeout.ts +5 -0
- package/server/appium-recorder/routes.ts +38 -15
- package/server/appium-recorder/script-import.test.ts +45 -0
- package/server/appium-recorder/tree-dump.ts +44 -0
- package/server/remote-agents/replay-integration.test.ts +4 -4
- package/src/App.vue +10 -10
- package/src/appium-recorder/AppiumPage.vue +58 -12
- package/src/appium-recorder/RunHistoryDialog.vue +91 -51
- package/src/appium-recorder/api.ts +4 -0
- package/src/appium-recorder/components/FlowStepEditor.vue +51 -9
- package/src/appium-recorder/components/RecordedSteps.vue +1 -0
- package/src/appium-recorder/components/ScriptParameterSettings.vue +44 -10
- package/src/appium-recorder/components/VariableTable.vue +44 -5
- package/src/appium-recorder/tree.ts +1 -0
- package/src/style.css +149 -53
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# 更新记录
|
|
2
|
+
## v1.1.4
|
|
3
|
+
- [Appium]修复持续刷新页面中的操作超时,以及可选元素未出现时误报失败的问题。
|
|
4
|
+
- [Appium]脚本列表支持批量导入并自动重建连接脚本关系;导入后保留列表页,不自动加载脚本。
|
|
5
|
+
- [Appium]点击节点改为优先使用组件定位,失败后才使用备用坐标;节点配置中显示备用坐标并过滤无效区域。
|
|
6
|
+
- [Appium]优化连接脚本参数和返回值配置,使用自适应卡片布局并简化空状态。
|
|
7
|
+
|
|
2
8
|
## v1.1.3
|
|
3
9
|
- [Appium]修复登录等点击操作已经触发 Activity 跳转后,UiAutomator2 仍等待点击确认、最终误报超时并占用命令队列的问题;元素点击先读取实时区域,再通过 ADB 点击中心坐标,并限制界面空闲等待时间。
|
|
4
10
|
|
package/README.md
CHANGED
|
@@ -1502,6 +1502,12 @@ Linux: ~/.local/share/android-midscene-automation
|
|
|
1502
1502
|
|
|
1503
1503
|
# 项目更新记录
|
|
1504
1504
|
|
|
1505
|
+
## v1.1.4
|
|
1506
|
+
- [Appium]修复持续刷新页面中的操作超时,以及可选元素未出现时误报失败的问题。
|
|
1507
|
+
- [Appium]脚本列表支持批量导入并自动重建连接脚本关系;导入后保留列表页,不自动加载脚本。
|
|
1508
|
+
- [Appium]点击节点改为优先使用组件定位,失败后才使用备用坐标;节点配置中显示备用坐标并过滤无效区域。
|
|
1509
|
+
- [Appium]优化连接脚本参数和返回值配置,使用自适应卡片布局并简化空状态。
|
|
1510
|
+
|
|
1505
1511
|
## v1.1.3
|
|
1506
1512
|
- [Appium]修复登录等点击操作已经触发 Activity 跳转后,UiAutomator2 仍等待点击确认、最终误报超时并占用命令队列的问题;元素点击先读取实时区域,再通过 ADB 点击中心坐标,并限制界面空闲等待时间。
|
|
1507
1513
|
|
package/package.json
CHANGED
|
@@ -18,7 +18,7 @@ import { stopAppOnDevice } from './stop-app';
|
|
|
18
18
|
import { DEFAULT_NODE_TIMEOUT_MS } from '../../src/appium-recorder/node-timeout';
|
|
19
19
|
import { startReplayVideo, type ReplayVideo } from './replay-video';
|
|
20
20
|
import { ConditionTimeoutError, AppiumServiceError } from './condition-timeout';
|
|
21
|
-
import { AppiumRequestTimeoutError, timedAppiumFetch, withElementDeadline } from './request-timeout';
|
|
21
|
+
import { AppiumRequestTimeoutError, remainingElementDeadlineMs, timedAppiumFetch, withElementDeadline } from './request-timeout';
|
|
22
22
|
import { textClickSelector } from '../../src/appium-recorder/text-click';
|
|
23
23
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
24
24
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
@@ -32,6 +32,7 @@ import { compareVisualChangeFrames, type VisualChangeRegion } from './visual-cha
|
|
|
32
32
|
import { longPressMode, validateLongPress } from '../../src/appium-recorder/long-press';
|
|
33
33
|
import { defaultFlowKind, flowBranchLabel } from '../../src/appium-recorder/flow-labels';
|
|
34
34
|
import { isNativeStateCondition, nativeControlName, matchesNativeControl, parseNativeBoolean } from '../../src/appium-recorder/native-control-state';
|
|
35
|
+
import { findHierarchyBounds, readFreshWindowHierarchy } from './tree-dump';
|
|
35
36
|
|
|
36
37
|
type AppiumSessionResponse = {
|
|
37
38
|
value?: {
|
|
@@ -50,7 +51,8 @@ type AppiumValueResponse<T> = {
|
|
|
50
51
|
};
|
|
51
52
|
|
|
52
53
|
const ELEMENT_KEY = 'element-6066-11e4-a52e-4f735466cecf';
|
|
53
|
-
const UIAUTOMATOR_IDLE_TIMEOUT_MS =
|
|
54
|
+
const UIAUTOMATOR_IDLE_TIMEOUT_MS = 0;
|
|
55
|
+
const MIN_ELEMENT_ATTEMPT_BUDGET_MS = 100;
|
|
54
56
|
const configuredAppiumServerUrl = () => (process.env.APPIUM_SERVER_URL || 'http://127.0.0.1:4723').replace(/\/+$/, '');
|
|
55
57
|
|
|
56
58
|
class ReplayStoppedError extends Error {
|
|
@@ -85,6 +87,7 @@ const replayContext = new AsyncLocalStorage<{
|
|
|
85
87
|
softFailureCount: number;
|
|
86
88
|
flowEnded?: boolean;
|
|
87
89
|
selectVideoScript?: (id: string, name: string, force?: boolean) => Promise<void>;
|
|
90
|
+
sessionId?: string;
|
|
88
91
|
}>();
|
|
89
92
|
|
|
90
93
|
const appiumServerUrl = () => replayContext.getStore()?.serverUrl || configuredAppiumServerUrl();
|
|
@@ -152,6 +155,19 @@ function adbTap(deviceId: string, x: number, y: number) {
|
|
|
152
155
|
});
|
|
153
156
|
}
|
|
154
157
|
|
|
158
|
+
function adbKey(deviceId: string, keyCode: number) {
|
|
159
|
+
throwIfReplayStopped();
|
|
160
|
+
return new Promise<void>((resolve, reject) => {
|
|
161
|
+
execFile(getAdbCommand(), ['-s', deviceId, 'shell', 'input', 'keyevent', String(Math.round(keyCode))], { timeout: 10000, signal: replayContext.getStore()?.signal }, (error, _stdout, stderr) => {
|
|
162
|
+
if (error) {
|
|
163
|
+
reject(new Error(stderr || error.message));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
resolve();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
155
171
|
export function launchAppOnDevice(deviceId: string, packageName: string, signal = replayContext.getStore()?.signal) {
|
|
156
172
|
signal?.throwIfAborted();
|
|
157
173
|
if (!packageName) return Promise.reject(new Error('启动 APP 缺少包名'));
|
|
@@ -288,12 +304,15 @@ async function appiumRequest<T>(
|
|
|
288
304
|
init?: RequestInit,
|
|
289
305
|
options?: { ignoreAbort?: boolean; timeoutMs?: number },
|
|
290
306
|
) {
|
|
291
|
-
const requestUrl = `${appiumServerUrl()}${path}`;
|
|
292
307
|
const context = replayContext.getStore();
|
|
308
|
+
const effectivePath = context?.sessionId
|
|
309
|
+
? path.replace(/^\/session\/[^/]+/, `/session/${context.sessionId}`)
|
|
310
|
+
: path;
|
|
311
|
+
const requestUrl = `${appiumServerUrl()}${effectivePath}`;
|
|
293
312
|
const method = init?.method || 'GET';
|
|
294
313
|
const startedAt = Date.now();
|
|
295
314
|
if (!options?.ignoreAbort) throwIfReplayStopped();
|
|
296
|
-
context?.appiumLog(`[Appium] 请求:${method} ${
|
|
315
|
+
context?.appiumLog(`[Appium] 请求:${method} ${effectivePath}`);
|
|
297
316
|
let response: Response;
|
|
298
317
|
let responseText: string;
|
|
299
318
|
try {
|
|
@@ -305,15 +324,15 @@ async function appiumRequest<T>(
|
|
|
305
324
|
'Content-Type': 'application/json',
|
|
306
325
|
...(init?.headers || {}),
|
|
307
326
|
},
|
|
308
|
-
}, options?.timeoutMs ?? (
|
|
327
|
+
}, options?.timeoutMs ?? (effectivePath === '/session' && method === 'POST' ? 120000 : 30000));
|
|
309
328
|
response = result.response;
|
|
310
329
|
responseText = result.text;
|
|
311
330
|
} catch (error) {
|
|
312
331
|
if (isReplayStopped(error) && !options?.ignoreAbort) {
|
|
313
|
-
context?.appiumLog(`[Appium] 取消:${method} ${
|
|
332
|
+
context?.appiumLog(`[Appium] 取消:${method} ${effectivePath}`);
|
|
314
333
|
throw new ReplayStoppedError();
|
|
315
334
|
}
|
|
316
|
-
context?.appiumLog(`[Appium] 异常:${method} ${
|
|
335
|
+
context?.appiumLog(`[Appium] 异常:${method} ${effectivePath}(${errorDetail(error)})`);
|
|
317
336
|
if (error instanceof AppiumRequestTimeoutError) throw error;
|
|
318
337
|
if (isTimeoutError(error)) {
|
|
319
338
|
throw new AppiumServiceError(`Appium 请求超时 ${requestUrl},${errorDetail(error)}`);
|
|
@@ -321,7 +340,7 @@ async function appiumRequest<T>(
|
|
|
321
340
|
throw new AppiumServiceError(`无法连接 Appium 服务 ${requestUrl},${errorDetail(error)}`);
|
|
322
341
|
}
|
|
323
342
|
|
|
324
|
-
context?.appiumLog(`[Appium] 响应:HTTP ${response.status} ${method} ${
|
|
343
|
+
context?.appiumLog(`[Appium] 响应:HTTP ${response.status} ${method} ${effectivePath}(${Date.now() - startedAt}ms)`);
|
|
325
344
|
const payload = (() => {
|
|
326
345
|
try {
|
|
327
346
|
return responseText ? JSON.parse(responseText) : {};
|
|
@@ -342,11 +361,15 @@ async function appiumRequest<T>(
|
|
|
342
361
|
responseText ? `原始响应:\n${responseText}` : '',
|
|
343
362
|
].filter(Boolean).join('\n');
|
|
344
363
|
const ErrorType = ['no such element', 'stale element reference'].includes(payload.value?.error || payload.error || '') ? Error : AppiumServiceError;
|
|
345
|
-
throw new ErrorType(`Appium ${method} ${
|
|
364
|
+
throw new ErrorType(`Appium ${method} ${effectivePath} 失败(HTTP ${response.status}):\n${detail || '未返回错误详情'}`);
|
|
346
365
|
}
|
|
347
366
|
return payload;
|
|
348
367
|
}
|
|
349
368
|
|
|
369
|
+
function appiumNodeRequest<T>(path: string, init?: RequestInit) {
|
|
370
|
+
return appiumRequest<T>(path, init, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS });
|
|
371
|
+
}
|
|
372
|
+
|
|
350
373
|
function replayFrameSelector(step: AppiumRecordedStepRecord) {
|
|
351
374
|
if (step.type === 'textClick') return `${step.flow?.textMatch === 'exact' ? '精准匹配' : '模糊匹配'}:${step.value || ''}`;
|
|
352
375
|
const selector = step.selector;
|
|
@@ -421,8 +444,8 @@ async function createSession(deviceId: string) {
|
|
|
421
444
|
'appium:udid': deviceId,
|
|
422
445
|
'appium:autoLaunch': false,
|
|
423
446
|
'appium:noReset': true,
|
|
424
|
-
// 实时画面、动画和频繁无障碍事件可能让 UiAutomator2
|
|
425
|
-
//
|
|
447
|
+
// 实时画面、动画和频繁无障碍事件可能让 UiAutomator2 始终等不到界面空闲。
|
|
448
|
+
// 节点已有显式等待,因此关闭驱动内部空闲等待,避免元素查询和点击确认占住命令队列。
|
|
426
449
|
'appium:settings': { waitForIdleTimeout: UIAUTOMATOR_IDLE_TIMEOUT_MS },
|
|
427
450
|
// 托管进程由 finally 释放;外部服务保留回收期限,避免创建请求取消后遗留永久会话。
|
|
428
451
|
'appium:newCommandTimeout': usesManagedAppiumServer() ? 0 : 60,
|
|
@@ -448,6 +471,40 @@ async function resetUiAutomator2(deviceId: string) {
|
|
|
448
471
|
await wait(800);
|
|
449
472
|
}
|
|
450
473
|
|
|
474
|
+
function adbHierarchyText(command: string, args: string[]) {
|
|
475
|
+
return new Promise<string>((resolve, reject) => {
|
|
476
|
+
execFile(command, args, {
|
|
477
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
478
|
+
timeout: DEFAULT_NODE_TIMEOUT_MS,
|
|
479
|
+
signal: replayContext.getStore()?.signal,
|
|
480
|
+
}, (error, stdout, stderr) => {
|
|
481
|
+
if (error) reject(new Error(stderr || error.message));
|
|
482
|
+
else resolve(stdout);
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function recoverOptionalLookup(deviceId: string, sessionId: string, step: AppiumRecordedStepRecord) {
|
|
488
|
+
const context = replayContext.getStore();
|
|
489
|
+
context?.appiumLog(`[Appium] ${step.label} 查询超时,正在重建 UiAutomator2 会话并通过 ADB 组件树判断`);
|
|
490
|
+
await resetUiAutomator2(deviceId);
|
|
491
|
+
await appiumRequest(`/session/${sessionId}`, { method: 'DELETE' }, { ignoreAbort: true, timeoutMs: 1000 })
|
|
492
|
+
.catch(error => context?.appiumLog(`[Appium] 旧会话释放未及时返回:${errorDetail(error)}`));
|
|
493
|
+
|
|
494
|
+
let bounds: ReturnType<typeof findHierarchyBounds>;
|
|
495
|
+
try {
|
|
496
|
+
const xml = await readFreshWindowHierarchy(deviceId, getAdbCommand(), adbHierarchyText);
|
|
497
|
+
bounds = findHierarchyBounds(xml, [step.selector, ...(step.selectorChain || [])].filter(Boolean) as NonNullable<AppiumRecordedStepRecord['selector']>[]);
|
|
498
|
+
} catch (error) {
|
|
499
|
+
context?.appiumLog(`[ADB] 可选元素组件树检查失败,按未出现处理:${errorDetail(error)}`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const replacementSessionId = await createSession(deviceId);
|
|
503
|
+
if (context) context.sessionId = replacementSessionId;
|
|
504
|
+
context?.appiumLog(`[Appium] UiAutomator2 会话已恢复:${replacementSessionId}`);
|
|
505
|
+
return bounds;
|
|
506
|
+
}
|
|
507
|
+
|
|
451
508
|
function toAppiumUsing(selector: NonNullable<AppiumRecordedStepRecord['selector']>) {
|
|
452
509
|
if (selector.strategy === 'accessibilityId') return { using: 'accessibility id', value: selector.value || '' };
|
|
453
510
|
if (selector.strategy === 'id') return { using: 'id', value: selector.value || '' };
|
|
@@ -481,37 +538,39 @@ async function findElement(sessionId: string, step: AppiumRecordedStepRecord, ti
|
|
|
481
538
|
async function findElementWithinDeadline(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
482
539
|
if (!step.selector) throw new Error(`${step.label} 缺少 selector`);
|
|
483
540
|
const attempts: string[] = [];
|
|
541
|
+
let requestAttempted = false;
|
|
542
|
+
let lastError: unknown;
|
|
484
543
|
|
|
485
544
|
if (step.contextSelector) {
|
|
486
545
|
try {
|
|
487
546
|
attempts.push(`context ${step.contextSelector.strategy} ${step.contextSelector.value || ''} -> ${step.selector.strategy} ${step.selector.value || ''}`);
|
|
547
|
+
requestAttempted = true;
|
|
488
548
|
const parentId = await findElementBySelector(sessionId, step.contextSelector);
|
|
549
|
+
if (remainingElementDeadlineMs() < MIN_ELEMENT_ATTEMPT_BUDGET_MS) throw new Error('元素定位时间已用完');
|
|
489
550
|
return await findElementBySelector(sessionId, step.selector, parentId);
|
|
490
551
|
} catch (error) {
|
|
491
552
|
throwIfReplayStopped(error);
|
|
553
|
+
lastError = error;
|
|
492
554
|
// Continue with the fallback chain below.
|
|
493
555
|
}
|
|
494
556
|
}
|
|
495
557
|
|
|
496
558
|
// 唯一原生定位优先;非唯一元素仍保留父级与备用 XPath 的精确匹配。
|
|
497
559
|
const preferPrimary = step.selector.unique === true && ['id', 'accessibilityId'].includes(step.selector.strategy);
|
|
498
|
-
|
|
560
|
+
const selectors = [...(preferPrimary ? [step.selector] : []), ...(step.selectorChain || []), ...(!preferPrimary ? [step.selector] : [])]
|
|
561
|
+
.filter((selector, index, list) => list.findIndex(item => item.strategy === selector.strategy && item.value === selector.value) === index);
|
|
562
|
+
for (const selector of selectors) {
|
|
563
|
+
if (requestAttempted && remainingElementDeadlineMs() < MIN_ELEMENT_ATTEMPT_BUDGET_MS) break;
|
|
499
564
|
try {
|
|
500
565
|
attempts.push(`${selector.strategy} ${selector.value || ''}`);
|
|
566
|
+
requestAttempted = true;
|
|
501
567
|
return await findElementBySelector(sessionId, selector);
|
|
502
568
|
} catch (error) {
|
|
503
569
|
throwIfReplayStopped(error);
|
|
504
|
-
|
|
570
|
+
lastError = error;
|
|
505
571
|
}
|
|
506
572
|
}
|
|
507
|
-
|
|
508
|
-
try {
|
|
509
|
-
attempts.push(`${step.selector.strategy} ${step.selector.value || ''}`);
|
|
510
|
-
return await findElementBySelector(sessionId, step.selector);
|
|
511
|
-
} catch (error) {
|
|
512
|
-
throwIfReplayStopped(error);
|
|
513
|
-
throw new Error(`${step.label} 未找到元素;尝试过:${attempts.filter(Boolean).join(';') || '-'};${errorDetail(error)}`);
|
|
514
|
-
}
|
|
573
|
+
throw new Error(`${step.label} 未找到元素;尝试过:${attempts.filter(Boolean).join(';') || '-'};${errorDetail(lastError)}`);
|
|
515
574
|
}
|
|
516
575
|
|
|
517
576
|
// 图像判断不能使用通用定位的“第一个匹配”及坐标回退,父级和子级均须唯一。
|
|
@@ -519,7 +578,7 @@ async function imageCheckRegion(sessionId: string, step: AppiumRecordedStepRecor
|
|
|
519
578
|
async function unique(selector: AppiumRecordedStepRecord['selector'], parent?: string) {
|
|
520
579
|
if (!selector || selector.strategy === 'bounds') throw new Error('缺少可唯一定位的组件信息');
|
|
521
580
|
const path = parent ? `/session/${sessionId}/element/${parent}/elements` : `/session/${sessionId}/elements`;
|
|
522
|
-
const payload = await
|
|
581
|
+
const payload = await appiumNodeRequest<AppiumValueResponse<Array<Record<string, string>>>>(path, {
|
|
523
582
|
method: 'POST', body: JSON.stringify(toAppiumUsing(selector)),
|
|
524
583
|
});
|
|
525
584
|
if (payload.value?.length !== 1) throw new Error(`目标组件匹配 ${payload.value?.length || 0} 个,请限定父级或条目文字`);
|
|
@@ -529,13 +588,22 @@ async function imageCheckRegion(sessionId: string, step: AppiumRecordedStepRecor
|
|
|
529
588
|
}
|
|
530
589
|
const parent = step.contextSelector ? await unique(step.contextSelector) : undefined;
|
|
531
590
|
const id = await unique(step.selector, parent);
|
|
532
|
-
const rect = await
|
|
591
|
+
const rect = await appiumNodeRequest<AppiumValueResponse<VisualChangeRegion>>(`/session/${sessionId}/element/${id}/rect`);
|
|
533
592
|
if (!rect.value) throw new Error('无法读取组件实时区域');
|
|
534
593
|
return rect.value;
|
|
535
594
|
}
|
|
536
595
|
|
|
596
|
+
function hasUsableFallbackCoordinates(step: AppiumRecordedStepRecord): step is AppiumRecordedStepRecord & {
|
|
597
|
+
fallback: { strategy: 'bounds'; centerX: number; centerY: number };
|
|
598
|
+
} {
|
|
599
|
+
return step.fallback?.strategy === 'bounds'
|
|
600
|
+
&& Number.isFinite(step.fallback.centerX)
|
|
601
|
+
&& Number.isFinite(step.fallback.centerY)
|
|
602
|
+
&& (Number(step.fallback.centerX) !== 0 || Number(step.fallback.centerY) !== 0);
|
|
603
|
+
}
|
|
604
|
+
|
|
537
605
|
async function tapFallback(deviceId: string, step: AppiumRecordedStepRecord) {
|
|
538
|
-
if (
|
|
606
|
+
if (!hasUsableFallbackCoordinates(step)) {
|
|
539
607
|
throw new Error(`${step.label} 没有可用点击坐标`);
|
|
540
608
|
}
|
|
541
609
|
await adbTap(deviceId, Number(step.fallback.centerX), Number(step.fallback.centerY));
|
|
@@ -548,7 +616,7 @@ async function tapLocatedElement(
|
|
|
548
616
|
step?: AppiumRecordedStepRecord,
|
|
549
617
|
) {
|
|
550
618
|
try {
|
|
551
|
-
const payload = await
|
|
619
|
+
const payload = await appiumNodeRequest<AppiumValueResponse<VisualChangeRegion>>(`/session/${sessionId}/element/${elementId}/rect`);
|
|
552
620
|
const rect = payload.value;
|
|
553
621
|
if (rect && [rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) && rect.width > 0 && rect.height > 0) {
|
|
554
622
|
await adbTap(deviceId, rect.x + rect.width / 2, rect.y + rect.height / 2);
|
|
@@ -566,17 +634,50 @@ async function tapLocatedElement(
|
|
|
566
634
|
throw new Error('无法读取目标元素的实时点击区域');
|
|
567
635
|
}
|
|
568
636
|
|
|
637
|
+
async function longPressLocatedElement(
|
|
638
|
+
sessionId: string,
|
|
639
|
+
deviceId: string,
|
|
640
|
+
elementId: string,
|
|
641
|
+
step: AppiumRecordedStepRecord,
|
|
642
|
+
duration: number,
|
|
643
|
+
) {
|
|
644
|
+
try {
|
|
645
|
+
const payload = await appiumNodeRequest<AppiumValueResponse<VisualChangeRegion>>(`/session/${sessionId}/element/${elementId}/rect`);
|
|
646
|
+
const rect = payload.value;
|
|
647
|
+
if (rect && [rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) && rect.width > 0 && rect.height > 0) {
|
|
648
|
+
const x = rect.x + rect.width / 2;
|
|
649
|
+
const y = rect.y + rect.height / 2;
|
|
650
|
+
await adbSwipe(deviceId, { startX: x, startY: y, endX: x, endY: y, duration });
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
} catch (error) {
|
|
654
|
+
throwIfReplayStopped(error);
|
|
655
|
+
if (isStaleElementError(error)) throw error;
|
|
656
|
+
}
|
|
657
|
+
if (!hasUsableFallbackCoordinates(step)) {
|
|
658
|
+
throw new Error('无法读取目标元素的实时长按区域');
|
|
659
|
+
}
|
|
660
|
+
const x = Number(step.fallback.centerX);
|
|
661
|
+
const y = Number(step.fallback.centerY);
|
|
662
|
+
await adbSwipe(deviceId, { startX: x, startY: y, endX: x, endY: y, duration });
|
|
663
|
+
}
|
|
664
|
+
|
|
569
665
|
async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
570
666
|
const startedAt = Date.now();
|
|
571
667
|
const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
572
668
|
let lastError: unknown;
|
|
573
|
-
|
|
669
|
+
let attempted = false;
|
|
670
|
+
while (true) {
|
|
671
|
+
const remaining = timeoutMs - (Date.now() - startedAt);
|
|
672
|
+
if (remaining <= 0 || (attempted && remaining < MIN_ELEMENT_ATTEMPT_BUDGET_MS)) break;
|
|
673
|
+
attempted = true;
|
|
574
674
|
try {
|
|
575
|
-
return await findElement(sessionId, step,
|
|
675
|
+
return await findElement(sessionId, step, remaining);
|
|
576
676
|
} catch (error) {
|
|
577
677
|
throwIfReplayStopped(error);
|
|
578
678
|
lastError = error;
|
|
579
|
-
|
|
679
|
+
const pauseMs = Math.min(500, timeoutMs - (Date.now() - startedAt));
|
|
680
|
+
if (pauseMs > 0) await wait(pauseMs);
|
|
580
681
|
}
|
|
581
682
|
}
|
|
582
683
|
throw new ConditionTimeoutError(`${step.label} 等待元素超时(${timeoutMs}ms):${errorDetail(lastError)}`);
|
|
@@ -585,28 +686,50 @@ async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord)
|
|
|
585
686
|
async function findOptionalElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
586
687
|
const startedAt = Date.now();
|
|
587
688
|
const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
588
|
-
|
|
689
|
+
let attempted = false;
|
|
690
|
+
while (true) {
|
|
691
|
+
const remaining = timeoutMs - (Date.now() - startedAt);
|
|
692
|
+
if (remaining <= 0 || (attempted && remaining < MIN_ELEMENT_ATTEMPT_BUDGET_MS)) return '';
|
|
693
|
+
attempted = true;
|
|
589
694
|
try {
|
|
590
|
-
return await findElement(sessionId, step,
|
|
695
|
+
return await findElement(sessionId, step, remaining);
|
|
591
696
|
} catch (error) {
|
|
592
697
|
throwIfReplayStopped(error);
|
|
593
|
-
|
|
698
|
+
const pauseMs = Math.min(250, timeoutMs - (Date.now() - startedAt));
|
|
699
|
+
if (pauseMs > 0) await wait(pauseMs);
|
|
594
700
|
}
|
|
595
701
|
}
|
|
596
|
-
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async function findOptionalElementWithRecovery(
|
|
705
|
+
sessionId: string,
|
|
706
|
+
deviceId: string,
|
|
707
|
+
step: AppiumRecordedStepRecord,
|
|
708
|
+
) {
|
|
709
|
+
try {
|
|
710
|
+
return { elementId: await findOptionalElement(sessionId, step), recovered: false as const };
|
|
711
|
+
} catch (error) {
|
|
712
|
+
if (!(error instanceof AppiumRequestTimeoutError)) throw error;
|
|
713
|
+
return { bounds: await recoverOptionalLookup(deviceId, sessionId, step), recovered: true as const };
|
|
714
|
+
}
|
|
597
715
|
}
|
|
598
716
|
|
|
599
717
|
async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
600
718
|
const startedAt = Date.now();
|
|
601
719
|
const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
602
|
-
|
|
720
|
+
let attempted = false;
|
|
721
|
+
while (true) {
|
|
722
|
+
const remaining = timeoutMs - (Date.now() - startedAt);
|
|
723
|
+
if (remaining <= 0 || (attempted && remaining < MIN_ELEMENT_ATTEMPT_BUDGET_MS)) break;
|
|
724
|
+
attempted = true;
|
|
603
725
|
try {
|
|
604
|
-
await findElement(sessionId, step,
|
|
726
|
+
await findElement(sessionId, step, remaining);
|
|
605
727
|
} catch (error) {
|
|
606
728
|
throwIfReplayStopped(error);
|
|
607
729
|
return;
|
|
608
730
|
}
|
|
609
|
-
|
|
731
|
+
const pauseMs = Math.min(500, timeoutMs - (Date.now() - startedAt));
|
|
732
|
+
if (pauseMs > 0) await wait(pauseMs);
|
|
610
733
|
}
|
|
611
734
|
throw new ConditionTimeoutError(`${step.label} 等待消失超时`);
|
|
612
735
|
}
|
|
@@ -814,7 +937,7 @@ async function runStep(
|
|
|
814
937
|
if (!scope) throw new Error('变量作用域未初始化');
|
|
815
938
|
const elementId = await findElement(sessionId, step);
|
|
816
939
|
const endpoint = config.attribute === 'text' ? 'text' : `attribute/${encodeURIComponent(config.attribute)}`;
|
|
817
|
-
const payload = await
|
|
940
|
+
const payload = await appiumNodeRequest<AppiumValueResponse<unknown>>(`/session/${sessionId}/element/${elementId}/${endpoint}`);
|
|
818
941
|
if (payload.value === null || payload.value === undefined) throw new Error(`组件不存在属性:${config.attribute}`);
|
|
819
942
|
scope.set({ name: config.name, value: String(payload.value), sensitive: config.sensitive }, true);
|
|
820
943
|
return `已提取变量:${config.name}`;
|
|
@@ -830,10 +953,7 @@ async function runStep(
|
|
|
830
953
|
}
|
|
831
954
|
|
|
832
955
|
if (step.type === 'key') {
|
|
833
|
-
await
|
|
834
|
-
method: 'POST',
|
|
835
|
-
body: JSON.stringify({ keycode: step.keyCode || 4 }),
|
|
836
|
-
});
|
|
956
|
+
await adbKey(deviceId, step.keyCode || 4);
|
|
837
957
|
return;
|
|
838
958
|
}
|
|
839
959
|
|
|
@@ -893,11 +1013,26 @@ async function runStep(
|
|
|
893
1013
|
if (error) throw new Error(`${step.label}:${error}`);
|
|
894
1014
|
const duration = step.timeoutMs ?? 800;
|
|
895
1015
|
if (longPressMode(step) === 'element') {
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1016
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1017
|
+
try {
|
|
1018
|
+
const elementId = await findElement(sessionId, step);
|
|
1019
|
+
await longPressLocatedElement(sessionId, deviceId, elementId, step, duration);
|
|
1020
|
+
return `已长按元素 ${duration}ms`;
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
throwIfReplayStopped(error);
|
|
1023
|
+
if (attempt === 0 && isStaleElementError(error)) {
|
|
1024
|
+
await wait(300);
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
if (isStaleElementError(error) && hasUsableFallbackCoordinates(step)) {
|
|
1028
|
+
const x = Number(step.fallback.centerX);
|
|
1029
|
+
const y = Number(step.fallback.centerY);
|
|
1030
|
+
await adbSwipe(deviceId, { startX: x, startY: y, endX: x, endY: y, duration });
|
|
1031
|
+
return `实时元素已失效,已按录制坐标长按 ${duration}ms`;
|
|
1032
|
+
}
|
|
1033
|
+
throw error;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
901
1036
|
return `已长按元素 ${duration}ms`;
|
|
902
1037
|
}
|
|
903
1038
|
const x = Number(step.fallback!.centerX);
|
|
@@ -909,7 +1044,7 @@ async function runStep(
|
|
|
909
1044
|
if (step.type === 'pinch') {
|
|
910
1045
|
if (!step.pinch) throw new Error(`${step.label} 缺少缩放参数`);
|
|
911
1046
|
const size = Math.round(Math.min(500, Math.max(120, step.pinch.centerX, step.pinch.centerY)));
|
|
912
|
-
await
|
|
1047
|
+
await appiumNodeRequest(`/session/${sessionId}/execute/sync`, {
|
|
913
1048
|
method: 'POST',
|
|
914
1049
|
body: JSON.stringify({
|
|
915
1050
|
script: step.pinch.direction === 'out' ? 'mobile: pinchOpenGesture' : 'mobile: pinchCloseGesture',
|
|
@@ -931,16 +1066,22 @@ async function runStep(
|
|
|
931
1066
|
}
|
|
932
1067
|
|
|
933
1068
|
if (step.type === 'tapIfExists') {
|
|
934
|
-
const
|
|
935
|
-
if (
|
|
936
|
-
|
|
1069
|
+
const lookup = await findOptionalElementWithRecovery(sessionId, deviceId, step);
|
|
1070
|
+
if (lookup.recovered) {
|
|
1071
|
+
if (!lookup.bounds) return '自动化会话已恢复,ADB 组件树未发现目标,已跳过';
|
|
1072
|
+
await adbTap(deviceId, lookup.bounds.centerX, lookup.bounds.centerY);
|
|
1073
|
+
return '自动化会话已恢复,ADB 组件树发现目标并已点击';
|
|
1074
|
+
}
|
|
1075
|
+
if (!lookup.elementId) return '未出现,已跳过';
|
|
1076
|
+
await tapLocatedElement(sessionId, deviceId, lookup.elementId, step);
|
|
937
1077
|
return '已出现,已点击';
|
|
938
1078
|
}
|
|
939
1079
|
|
|
940
1080
|
if (step.type === 'inputIfExists') {
|
|
941
|
-
const
|
|
942
|
-
if (
|
|
943
|
-
|
|
1081
|
+
const lookup = await findOptionalElementWithRecovery(sessionId, deviceId, step);
|
|
1082
|
+
if (lookup.recovered) return lookup.bounds ? '自动化会话已恢复,目标存在但无法安全输入,已跳过' : '自动化会话已恢复,目标未出现,已跳过';
|
|
1083
|
+
if (!lookup.elementId) return '未出现,已跳过';
|
|
1084
|
+
await appiumNodeRequest(`/session/${sessionId}/element/${lookup.elementId}/value`, {
|
|
944
1085
|
method: 'POST',
|
|
945
1086
|
body: JSON.stringify({ text: step.value || '', value: [...(step.value || '')] }),
|
|
946
1087
|
});
|
|
@@ -948,9 +1089,10 @@ async function runStep(
|
|
|
948
1089
|
}
|
|
949
1090
|
|
|
950
1091
|
if (step.type === 'clearIfExists') {
|
|
951
|
-
const
|
|
952
|
-
if (
|
|
953
|
-
|
|
1092
|
+
const lookup = await findOptionalElementWithRecovery(sessionId, deviceId, step);
|
|
1093
|
+
if (lookup.recovered) return lookup.bounds ? '自动化会话已恢复,目标存在但无法安全清空,已跳过' : '自动化会话已恢复,目标未出现,已跳过';
|
|
1094
|
+
if (!lookup.elementId) return '未出现,已跳过';
|
|
1095
|
+
await appiumNodeRequest(`/session/${sessionId}/element/${lookup.elementId}/clear`, {
|
|
954
1096
|
method: 'POST',
|
|
955
1097
|
body: JSON.stringify({}),
|
|
956
1098
|
});
|
|
@@ -958,13 +1100,11 @@ async function runStep(
|
|
|
958
1100
|
}
|
|
959
1101
|
|
|
960
1102
|
if (step.type === 'backIfExists') {
|
|
961
|
-
const
|
|
962
|
-
if (!
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
});
|
|
967
|
-
return '已出现,已返回';
|
|
1103
|
+
const lookup = await findOptionalElementWithRecovery(sessionId, deviceId, step);
|
|
1104
|
+
if (lookup.recovered && !lookup.bounds) return '自动化会话已恢复,目标未出现,已跳过';
|
|
1105
|
+
if (!lookup.recovered && !lookup.elementId) return '未出现,已跳过';
|
|
1106
|
+
await adbKey(deviceId, 4);
|
|
1107
|
+
return lookup.recovered ? '自动化会话已恢复,ADB 组件树发现目标并已返回' : '已出现,已返回';
|
|
968
1108
|
}
|
|
969
1109
|
|
|
970
1110
|
if (step.type === 'waitFor' || step.type === 'assertExists') {
|
|
@@ -974,7 +1114,7 @@ async function runStep(
|
|
|
974
1114
|
|
|
975
1115
|
if (step.type === 'assertText') {
|
|
976
1116
|
const elementId = await findElement(sessionId, step);
|
|
977
|
-
const payload = await
|
|
1117
|
+
const payload = await appiumNodeRequest<AppiumValueResponse<string>>(`/session/${sessionId}/element/${elementId}/text`);
|
|
978
1118
|
const actual = payload.value || '';
|
|
979
1119
|
if (!actual.includes(step.value || '')) throw new Error(`${step.label} 不匹配,实际文本:${actual || '-'}`);
|
|
980
1120
|
return;
|
|
@@ -1003,7 +1143,7 @@ async function runStep(
|
|
|
1003
1143
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1004
1144
|
try {
|
|
1005
1145
|
const elementId = await findElement(sessionId, step);
|
|
1006
|
-
await
|
|
1146
|
+
await appiumNodeRequest(`/session/${sessionId}/element/${elementId}/value`, {
|
|
1007
1147
|
method: 'POST',
|
|
1008
1148
|
body: JSON.stringify({ text: step.value || '', value: [...(step.value || '')] }),
|
|
1009
1149
|
});
|
|
@@ -1021,7 +1161,7 @@ async function runStep(
|
|
|
1021
1161
|
|
|
1022
1162
|
if (step.type === 'clearInput') {
|
|
1023
1163
|
const elementId = await findElement(sessionId, step);
|
|
1024
|
-
await
|
|
1164
|
+
await appiumNodeRequest(`/session/${sessionId}/element/${elementId}/clear`, {
|
|
1025
1165
|
method: 'POST',
|
|
1026
1166
|
body: JSON.stringify({}),
|
|
1027
1167
|
});
|
|
@@ -1032,7 +1172,7 @@ async function readNativeControlState(sessionId: string, step: AppiumRecordedSte
|
|
|
1032
1172
|
if (!isNativeStateCondition(step)) throw new Error('不是原生控件状态判断节点');
|
|
1033
1173
|
const elementId = await waitForElement(sessionId, step);
|
|
1034
1174
|
const attribute = async (name: string) => (
|
|
1035
|
-
await
|
|
1175
|
+
await appiumNodeRequest<AppiumValueResponse<unknown>>(`/session/${sessionId}/element/${elementId}/attribute/${name}`)
|
|
1036
1176
|
).value;
|
|
1037
1177
|
const className = await attribute('className');
|
|
1038
1178
|
if (!matchesNativeControl(step.type, className)) {
|
|
@@ -1051,20 +1191,34 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
1051
1191
|
if (step.type === 'loop') {
|
|
1052
1192
|
if (step.loop?.exitWhen === 'never') return false;
|
|
1053
1193
|
const selector = toAppiumUsing(step.selector!);
|
|
1054
|
-
const payload = await
|
|
1055
|
-
|
|
1056
|
-
|
|
1194
|
+
const payload = await withElementDeadline(step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS, () => (
|
|
1195
|
+
appiumNodeRequest<AppiumValueResponse<Record<string, string>[]>>(`/session/${sessionId}/elements`, {
|
|
1196
|
+
method: 'POST', body: JSON.stringify(selector),
|
|
1197
|
+
})
|
|
1198
|
+
));
|
|
1057
1199
|
if (!Array.isArray(payload.value)) throw new Error('循环退出条件查询返回了无效结果');
|
|
1058
1200
|
return step.loop?.exitWhen === 'exists' ? payload.value.length > 0 : payload.value.length === 0;
|
|
1059
1201
|
}
|
|
1060
1202
|
if (step.type === 'textClick') {
|
|
1061
1203
|
const selector = textClickSelector(step);
|
|
1062
|
-
const
|
|
1204
|
+
const timeoutMs = Math.max(0, step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS);
|
|
1205
|
+
const deadline = Date.now() + timeoutMs;
|
|
1206
|
+
let attempted = false;
|
|
1207
|
+
const timeoutResult = () => {
|
|
1208
|
+
if (step.timeoutMs === 0) return false;
|
|
1209
|
+
throw new ConditionTimeoutError(`文字点击等待匹配超时(${timeoutMs}ms)`);
|
|
1210
|
+
};
|
|
1063
1211
|
// 查询空结果才是未匹配;通信错误、重复匹配和点击失败走现有异常处理。
|
|
1064
1212
|
do {
|
|
1065
|
-
const
|
|
1066
|
-
|
|
1067
|
-
|
|
1213
|
+
const remaining = deadline - Date.now();
|
|
1214
|
+
if (attempted && (remaining <= 0 || remaining < MIN_ELEMENT_ATTEMPT_BUDGET_MS)) return timeoutResult();
|
|
1215
|
+
attempted = true;
|
|
1216
|
+
const requestBudget = timeoutMs === 0 ? DEFAULT_NODE_TIMEOUT_MS : remaining;
|
|
1217
|
+
const payload = await withElementDeadline(requestBudget, () => (
|
|
1218
|
+
appiumNodeRequest<AppiumValueResponse<Record<string, string>[]>>(`/session/${sessionId}/elements`, {
|
|
1219
|
+
method: 'POST', body: JSON.stringify(selector),
|
|
1220
|
+
})
|
|
1221
|
+
));
|
|
1068
1222
|
if (!Array.isArray(payload.value)) throw new Error('文字点击查询返回了无效结果');
|
|
1069
1223
|
if (payload.value.length > 1) throw new Error(`匹配到 ${payload.value.length} 个文字元素,请使用更精确的文字`);
|
|
1070
1224
|
if (payload.value.length === 1) {
|
|
@@ -1074,10 +1228,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
1074
1228
|
await tapLocatedElement(sessionId, deviceId, id);
|
|
1075
1229
|
return true;
|
|
1076
1230
|
}
|
|
1077
|
-
if (Date.now() >= deadline)
|
|
1078
|
-
if (step.timeoutMs === 0) return false;
|
|
1079
|
-
throw new ConditionTimeoutError(`文字点击等待匹配超时(${step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS}ms)`);
|
|
1080
|
-
}
|
|
1231
|
+
if (timeoutMs === 0 || Date.now() >= deadline) return timeoutResult();
|
|
1081
1232
|
await wait(Math.max(0, Math.min(500, deadline - Date.now())));
|
|
1082
1233
|
} while (true);
|
|
1083
1234
|
}
|
|
@@ -1094,7 +1245,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
1094
1245
|
}
|
|
1095
1246
|
if (step.type === 'assertText' || (step.type === 'assertExists' && step.value)) {
|
|
1096
1247
|
const elementId = await waitForElement(sessionId, step);
|
|
1097
|
-
const payload = await
|
|
1248
|
+
const payload = await appiumNodeRequest<AppiumValueResponse<string>>(`/session/${sessionId}/element/${elementId}/text`);
|
|
1098
1249
|
const actualText = payload.value || '';
|
|
1099
1250
|
const expectedText = step.value || '';
|
|
1100
1251
|
return step.flow?.textMatch === 'exact'
|
|
@@ -1574,6 +1725,7 @@ export async function replayAppiumScript(
|
|
|
1574
1725
|
visualCaptureNodes: new Set([script, ...scripts.values()].flatMap(item => item.steps.flatMap(step =>
|
|
1575
1726
|
step.visualChange ? [step.id, step.visualChange.startStepId || '', step.visualChange.endStepId || ''] : []))),
|
|
1576
1727
|
signal,
|
|
1728
|
+
sessionId: '',
|
|
1577
1729
|
appiumLog: (line) => { if (!scope.privacy.enabled) lines.push(line); },
|
|
1578
1730
|
serverUrl: configuredAppiumServerUrl(),
|
|
1579
1731
|
deviceId: targetDeviceId,
|
|
@@ -1625,6 +1777,7 @@ export async function replayAppiumScript(
|
|
|
1625
1777
|
await resetUiAutomator2(targetDeviceId);
|
|
1626
1778
|
sessionId = await createSession(targetDeviceId);
|
|
1627
1779
|
}
|
|
1780
|
+
context.sessionId = sessionId;
|
|
1628
1781
|
if (!managedAppium) {
|
|
1629
1782
|
const heartbeatAbort = new AbortController();
|
|
1630
1783
|
let heartbeatTask: Promise<unknown> | undefined;
|