android-midscene-automation 1.1.2 → 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 +9 -0
- package/README.md +9 -0
- package/package.json +1 -1
- package/server/appium-recorder/appium-runner.ts +258 -86
- package/server/appium-recorder/element-tap.test.ts +251 -0
- 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
|
@@ -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,29 +588,96 @@ 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 (
|
|
539
|
-
throw new Error(`${step.label}
|
|
606
|
+
if (!hasUsableFallbackCoordinates(step)) {
|
|
607
|
+
throw new Error(`${step.label} 没有可用点击坐标`);
|
|
540
608
|
}
|
|
541
609
|
await adbTap(deviceId, Number(step.fallback.centerX), Number(step.fallback.centerY));
|
|
542
610
|
}
|
|
543
611
|
|
|
612
|
+
async function tapLocatedElement(
|
|
613
|
+
sessionId: string,
|
|
614
|
+
deviceId: string,
|
|
615
|
+
elementId: string,
|
|
616
|
+
step?: AppiumRecordedStepRecord,
|
|
617
|
+
) {
|
|
618
|
+
try {
|
|
619
|
+
const payload = await appiumNodeRequest<AppiumValueResponse<VisualChangeRegion>>(`/session/${sessionId}/element/${elementId}/rect`);
|
|
620
|
+
const rect = payload.value;
|
|
621
|
+
if (rect && [rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) && rect.width > 0 && rect.height > 0) {
|
|
622
|
+
await adbTap(deviceId, rect.x + rect.width / 2, rect.y + rect.height / 2);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
} catch (error) {
|
|
626
|
+
throwIfReplayStopped(error);
|
|
627
|
+
// 普通点击的外层会重新定位一次,避免列表刷新后使用已失效元素的旧位置。
|
|
628
|
+
if (isStaleElementError(error)) throw error;
|
|
629
|
+
}
|
|
630
|
+
if (step) {
|
|
631
|
+
await tapFallback(deviceId, step);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
throw new Error('无法读取目标元素的实时点击区域');
|
|
635
|
+
}
|
|
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
|
+
|
|
544
665
|
async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
545
666
|
const startedAt = Date.now();
|
|
546
667
|
const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
547
668
|
let lastError: unknown;
|
|
548
|
-
|
|
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;
|
|
549
674
|
try {
|
|
550
|
-
return await findElement(sessionId, step,
|
|
675
|
+
return await findElement(sessionId, step, remaining);
|
|
551
676
|
} catch (error) {
|
|
552
677
|
throwIfReplayStopped(error);
|
|
553
678
|
lastError = error;
|
|
554
|
-
|
|
679
|
+
const pauseMs = Math.min(500, timeoutMs - (Date.now() - startedAt));
|
|
680
|
+
if (pauseMs > 0) await wait(pauseMs);
|
|
555
681
|
}
|
|
556
682
|
}
|
|
557
683
|
throw new ConditionTimeoutError(`${step.label} 等待元素超时(${timeoutMs}ms):${errorDetail(lastError)}`);
|
|
@@ -560,28 +686,50 @@ async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord)
|
|
|
560
686
|
async function findOptionalElement(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
561
687
|
const startedAt = Date.now();
|
|
562
688
|
const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
563
|
-
|
|
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;
|
|
564
694
|
try {
|
|
565
|
-
return await findElement(sessionId, step,
|
|
695
|
+
return await findElement(sessionId, step, remaining);
|
|
566
696
|
} catch (error) {
|
|
567
697
|
throwIfReplayStopped(error);
|
|
568
|
-
|
|
698
|
+
const pauseMs = Math.min(250, timeoutMs - (Date.now() - startedAt));
|
|
699
|
+
if (pauseMs > 0) await wait(pauseMs);
|
|
569
700
|
}
|
|
570
701
|
}
|
|
571
|
-
|
|
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
|
+
}
|
|
572
715
|
}
|
|
573
716
|
|
|
574
717
|
async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRecord) {
|
|
575
718
|
const startedAt = Date.now();
|
|
576
719
|
const timeoutMs = step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
577
|
-
|
|
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;
|
|
578
725
|
try {
|
|
579
|
-
await findElement(sessionId, step,
|
|
726
|
+
await findElement(sessionId, step, remaining);
|
|
580
727
|
} catch (error) {
|
|
581
728
|
throwIfReplayStopped(error);
|
|
582
729
|
return;
|
|
583
730
|
}
|
|
584
|
-
|
|
731
|
+
const pauseMs = Math.min(500, timeoutMs - (Date.now() - startedAt));
|
|
732
|
+
if (pauseMs > 0) await wait(pauseMs);
|
|
585
733
|
}
|
|
586
734
|
throw new ConditionTimeoutError(`${step.label} 等待消失超时`);
|
|
587
735
|
}
|
|
@@ -789,7 +937,7 @@ async function runStep(
|
|
|
789
937
|
if (!scope) throw new Error('变量作用域未初始化');
|
|
790
938
|
const elementId = await findElement(sessionId, step);
|
|
791
939
|
const endpoint = config.attribute === 'text' ? 'text' : `attribute/${encodeURIComponent(config.attribute)}`;
|
|
792
|
-
const payload = await
|
|
940
|
+
const payload = await appiumNodeRequest<AppiumValueResponse<unknown>>(`/session/${sessionId}/element/${elementId}/${endpoint}`);
|
|
793
941
|
if (payload.value === null || payload.value === undefined) throw new Error(`组件不存在属性:${config.attribute}`);
|
|
794
942
|
scope.set({ name: config.name, value: String(payload.value), sensitive: config.sensitive }, true);
|
|
795
943
|
return `已提取变量:${config.name}`;
|
|
@@ -805,10 +953,7 @@ async function runStep(
|
|
|
805
953
|
}
|
|
806
954
|
|
|
807
955
|
if (step.type === 'key') {
|
|
808
|
-
await
|
|
809
|
-
method: 'POST',
|
|
810
|
-
body: JSON.stringify({ keycode: step.keyCode || 4 }),
|
|
811
|
-
});
|
|
956
|
+
await adbKey(deviceId, step.keyCode || 4);
|
|
812
957
|
return;
|
|
813
958
|
}
|
|
814
959
|
|
|
@@ -868,11 +1013,26 @@ async function runStep(
|
|
|
868
1013
|
if (error) throw new Error(`${step.label}:${error}`);
|
|
869
1014
|
const duration = step.timeoutMs ?? 800;
|
|
870
1015
|
if (longPressMode(step) === 'element') {
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
+
}
|
|
876
1036
|
return `已长按元素 ${duration}ms`;
|
|
877
1037
|
}
|
|
878
1038
|
const x = Number(step.fallback!.centerX);
|
|
@@ -884,7 +1044,7 @@ async function runStep(
|
|
|
884
1044
|
if (step.type === 'pinch') {
|
|
885
1045
|
if (!step.pinch) throw new Error(`${step.label} 缺少缩放参数`);
|
|
886
1046
|
const size = Math.round(Math.min(500, Math.max(120, step.pinch.centerX, step.pinch.centerY)));
|
|
887
|
-
await
|
|
1047
|
+
await appiumNodeRequest(`/session/${sessionId}/execute/sync`, {
|
|
888
1048
|
method: 'POST',
|
|
889
1049
|
body: JSON.stringify({
|
|
890
1050
|
script: step.pinch.direction === 'out' ? 'mobile: pinchOpenGesture' : 'mobile: pinchCloseGesture',
|
|
@@ -906,19 +1066,22 @@ async function runStep(
|
|
|
906
1066
|
}
|
|
907
1067
|
|
|
908
1068
|
if (step.type === 'tapIfExists') {
|
|
909
|
-
const
|
|
910
|
-
if (
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
}
|
|
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);
|
|
915
1077
|
return '已出现,已点击';
|
|
916
1078
|
}
|
|
917
1079
|
|
|
918
1080
|
if (step.type === 'inputIfExists') {
|
|
919
|
-
const
|
|
920
|
-
if (
|
|
921
|
-
|
|
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`, {
|
|
922
1085
|
method: 'POST',
|
|
923
1086
|
body: JSON.stringify({ text: step.value || '', value: [...(step.value || '')] }),
|
|
924
1087
|
});
|
|
@@ -926,9 +1089,10 @@ async function runStep(
|
|
|
926
1089
|
}
|
|
927
1090
|
|
|
928
1091
|
if (step.type === 'clearIfExists') {
|
|
929
|
-
const
|
|
930
|
-
if (
|
|
931
|
-
|
|
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`, {
|
|
932
1096
|
method: 'POST',
|
|
933
1097
|
body: JSON.stringify({}),
|
|
934
1098
|
});
|
|
@@ -936,13 +1100,11 @@ async function runStep(
|
|
|
936
1100
|
}
|
|
937
1101
|
|
|
938
1102
|
if (step.type === 'backIfExists') {
|
|
939
|
-
const
|
|
940
|
-
if (!
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
});
|
|
945
|
-
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 组件树发现目标并已返回' : '已出现,已返回';
|
|
946
1108
|
}
|
|
947
1109
|
|
|
948
1110
|
if (step.type === 'waitFor' || step.type === 'assertExists') {
|
|
@@ -952,7 +1114,7 @@ async function runStep(
|
|
|
952
1114
|
|
|
953
1115
|
if (step.type === 'assertText') {
|
|
954
1116
|
const elementId = await findElement(sessionId, step);
|
|
955
|
-
const payload = await
|
|
1117
|
+
const payload = await appiumNodeRequest<AppiumValueResponse<string>>(`/session/${sessionId}/element/${elementId}/text`);
|
|
956
1118
|
const actual = payload.value || '';
|
|
957
1119
|
if (!actual.includes(step.value || '')) throw new Error(`${step.label} 不匹配,实际文本:${actual || '-'}`);
|
|
958
1120
|
return;
|
|
@@ -962,10 +1124,7 @@ async function runStep(
|
|
|
962
1124
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
963
1125
|
try {
|
|
964
1126
|
const elementId = await findElement(sessionId, step);
|
|
965
|
-
await
|
|
966
|
-
method: 'POST',
|
|
967
|
-
body: JSON.stringify({}),
|
|
968
|
-
});
|
|
1127
|
+
await tapLocatedElement(sessionId, deviceId, elementId, step);
|
|
969
1128
|
return;
|
|
970
1129
|
} catch (error) {
|
|
971
1130
|
throwIfReplayStopped(error);
|
|
@@ -984,7 +1143,7 @@ async function runStep(
|
|
|
984
1143
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
985
1144
|
try {
|
|
986
1145
|
const elementId = await findElement(sessionId, step);
|
|
987
|
-
await
|
|
1146
|
+
await appiumNodeRequest(`/session/${sessionId}/element/${elementId}/value`, {
|
|
988
1147
|
method: 'POST',
|
|
989
1148
|
body: JSON.stringify({ text: step.value || '', value: [...(step.value || '')] }),
|
|
990
1149
|
});
|
|
@@ -1002,7 +1161,7 @@ async function runStep(
|
|
|
1002
1161
|
|
|
1003
1162
|
if (step.type === 'clearInput') {
|
|
1004
1163
|
const elementId = await findElement(sessionId, step);
|
|
1005
|
-
await
|
|
1164
|
+
await appiumNodeRequest(`/session/${sessionId}/element/${elementId}/clear`, {
|
|
1006
1165
|
method: 'POST',
|
|
1007
1166
|
body: JSON.stringify({}),
|
|
1008
1167
|
});
|
|
@@ -1013,7 +1172,7 @@ async function readNativeControlState(sessionId: string, step: AppiumRecordedSte
|
|
|
1013
1172
|
if (!isNativeStateCondition(step)) throw new Error('不是原生控件状态判断节点');
|
|
1014
1173
|
const elementId = await waitForElement(sessionId, step);
|
|
1015
1174
|
const attribute = async (name: string) => (
|
|
1016
|
-
await
|
|
1175
|
+
await appiumNodeRequest<AppiumValueResponse<unknown>>(`/session/${sessionId}/element/${elementId}/attribute/${name}`)
|
|
1017
1176
|
).value;
|
|
1018
1177
|
const className = await attribute('className');
|
|
1019
1178
|
if (!matchesNativeControl(step.type, className)) {
|
|
@@ -1032,33 +1191,44 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
1032
1191
|
if (step.type === 'loop') {
|
|
1033
1192
|
if (step.loop?.exitWhen === 'never') return false;
|
|
1034
1193
|
const selector = toAppiumUsing(step.selector!);
|
|
1035
|
-
const payload = await
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
+
));
|
|
1038
1199
|
if (!Array.isArray(payload.value)) throw new Error('循环退出条件查询返回了无效结果');
|
|
1039
1200
|
return step.loop?.exitWhen === 'exists' ? payload.value.length > 0 : payload.value.length === 0;
|
|
1040
1201
|
}
|
|
1041
1202
|
if (step.type === 'textClick') {
|
|
1042
1203
|
const selector = textClickSelector(step);
|
|
1043
|
-
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
|
+
};
|
|
1044
1211
|
// 查询空结果才是未匹配;通信错误、重复匹配和点击失败走现有异常处理。
|
|
1045
1212
|
do {
|
|
1046
|
-
const
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
+
));
|
|
1049
1222
|
if (!Array.isArray(payload.value)) throw new Error('文字点击查询返回了无效结果');
|
|
1050
1223
|
if (payload.value.length > 1) throw new Error(`匹配到 ${payload.value.length} 个文字元素,请使用更精确的文字`);
|
|
1051
1224
|
if (payload.value.length === 1) {
|
|
1052
1225
|
const element = payload.value[0]!;
|
|
1053
1226
|
const id = element[ELEMENT_KEY] || element.ELEMENT;
|
|
1054
1227
|
if (!id) throw new Error('文字点击查询缺少元素 ID');
|
|
1055
|
-
await
|
|
1228
|
+
await tapLocatedElement(sessionId, deviceId, id);
|
|
1056
1229
|
return true;
|
|
1057
1230
|
}
|
|
1058
|
-
if (Date.now() >= deadline)
|
|
1059
|
-
if (step.timeoutMs === 0) return false;
|
|
1060
|
-
throw new ConditionTimeoutError(`文字点击等待匹配超时(${step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS}ms)`);
|
|
1061
|
-
}
|
|
1231
|
+
if (timeoutMs === 0 || Date.now() >= deadline) return timeoutResult();
|
|
1062
1232
|
await wait(Math.max(0, Math.min(500, deadline - Date.now())));
|
|
1063
1233
|
} while (true);
|
|
1064
1234
|
}
|
|
@@ -1075,7 +1245,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
1075
1245
|
}
|
|
1076
1246
|
if (step.type === 'assertText' || (step.type === 'assertExists' && step.value)) {
|
|
1077
1247
|
const elementId = await waitForElement(sessionId, step);
|
|
1078
|
-
const payload = await
|
|
1248
|
+
const payload = await appiumNodeRequest<AppiumValueResponse<string>>(`/session/${sessionId}/element/${elementId}/text`);
|
|
1079
1249
|
const actualText = payload.value || '';
|
|
1080
1250
|
const expectedText = step.value || '';
|
|
1081
1251
|
return step.flow?.textMatch === 'exact'
|
|
@@ -1555,6 +1725,7 @@ export async function replayAppiumScript(
|
|
|
1555
1725
|
visualCaptureNodes: new Set([script, ...scripts.values()].flatMap(item => item.steps.flatMap(step =>
|
|
1556
1726
|
step.visualChange ? [step.id, step.visualChange.startStepId || '', step.visualChange.endStepId || ''] : []))),
|
|
1557
1727
|
signal,
|
|
1728
|
+
sessionId: '',
|
|
1558
1729
|
appiumLog: (line) => { if (!scope.privacy.enabled) lines.push(line); },
|
|
1559
1730
|
serverUrl: configuredAppiumServerUrl(),
|
|
1560
1731
|
deviceId: targetDeviceId,
|
|
@@ -1606,6 +1777,7 @@ export async function replayAppiumScript(
|
|
|
1606
1777
|
await resetUiAutomator2(targetDeviceId);
|
|
1607
1778
|
sessionId = await createSession(targetDeviceId);
|
|
1608
1779
|
}
|
|
1780
|
+
context.sessionId = sessionId;
|
|
1609
1781
|
if (!managedAppium) {
|
|
1610
1782
|
const heartbeatAbort = new AbortController();
|
|
1611
1783
|
let heartbeatTask: Promise<unknown> | undefined;
|