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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
@@ -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 = 1000;
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} ${path}`);
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 ?? (path === '/session' && method === 'POST' ? 120000 : 30000));
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} ${path}`);
332
+ context?.appiumLog(`[Appium] 取消:${method} ${effectivePath}`);
314
333
  throw new ReplayStoppedError();
315
334
  }
316
- context?.appiumLog(`[Appium] 异常:${method} ${path}(${errorDetail(error)})`);
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} ${path}(${Date.now() - startedAt}ms)`);
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} ${path} 失败(HTTP ${response.status}):\n${detail || '未返回错误详情'}`);
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
- for (const selector of [...(preferPrimary ? [step.selector] : []), ...(step.selectorChain || [])]) {
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
- // Continue.
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 appiumRequest<AppiumValueResponse<Array<Record<string, string>>>>(path, {
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 appiumRequest<AppiumValueResponse<VisualChangeRegion>>(`/session/${sessionId}/element/${id}/rect`);
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 (step.fallback?.strategy !== 'bounds' || !Number.isFinite(step.fallback.centerX) || !Number.isFinite(step.fallback.centerY)) {
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 appiumRequest<AppiumValueResponse<VisualChangeRegion>>(`/session/${sessionId}/element/${elementId}/rect`);
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
- while (Date.now() - startedAt <= timeoutMs) {
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, Math.max(1, timeoutMs - (Date.now() - startedAt)));
675
+ return await findElement(sessionId, step, remaining);
576
676
  } catch (error) {
577
677
  throwIfReplayStopped(error);
578
678
  lastError = error;
579
- await wait(500);
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
- while (Date.now() - startedAt <= timeoutMs) {
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, Math.max(1, timeoutMs - (Date.now() - startedAt)));
695
+ return await findElement(sessionId, step, remaining);
591
696
  } catch (error) {
592
697
  throwIfReplayStopped(error);
593
- await wait(250);
698
+ const pauseMs = Math.min(250, timeoutMs - (Date.now() - startedAt));
699
+ if (pauseMs > 0) await wait(pauseMs);
594
700
  }
595
701
  }
596
- return '';
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
- while (Date.now() - startedAt <= timeoutMs) {
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, Math.max(1, timeoutMs - (Date.now() - startedAt)));
726
+ await findElement(sessionId, step, remaining);
605
727
  } catch (error) {
606
728
  throwIfReplayStopped(error);
607
729
  return;
608
730
  }
609
- await wait(500);
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 appiumRequest<AppiumValueResponse<unknown>>(`/session/${sessionId}/element/${elementId}/${endpoint}`);
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 appiumRequest(`/session/${sessionId}/appium/device/press_keycode`, {
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
- const elementId = await findElement(sessionId, step);
897
- await appiumRequest(`/session/${sessionId}/execute/sync`, {
898
- method: 'POST',
899
- body: JSON.stringify({ script: 'mobile: longClickGesture', args: [{ elementId, duration }] }),
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 appiumRequest(`/session/${sessionId}/execute/sync`, {
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 elementId = await findOptionalElement(sessionId, step);
935
- if (!elementId) return '未出现,已跳过';
936
- await tapLocatedElement(sessionId, deviceId, elementId, step);
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 elementId = await findOptionalElement(sessionId, step);
942
- if (!elementId) return '未出现,已跳过';
943
- await appiumRequest(`/session/${sessionId}/element/${elementId}/value`, {
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 elementId = await findOptionalElement(sessionId, step);
952
- if (!elementId) return '未出现,已跳过';
953
- await appiumRequest(`/session/${sessionId}/element/${elementId}/clear`, {
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 elementId = await findOptionalElement(sessionId, step);
962
- if (!elementId) return '未出现,已跳过';
963
- await appiumRequest(`/session/${sessionId}/appium/device/press_keycode`, {
964
- method: 'POST',
965
- body: JSON.stringify({ keycode: 4 }),
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 appiumRequest<AppiumValueResponse<string>>(`/session/${sessionId}/element/${elementId}/text`);
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 appiumRequest(`/session/${sessionId}/element/${elementId}/value`, {
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 appiumRequest(`/session/${sessionId}/element/${elementId}/clear`, {
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 appiumRequest<AppiumValueResponse<unknown>>(`/session/${sessionId}/element/${elementId}/attribute/${name}`)
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 appiumRequest<AppiumValueResponse<Record<string, string>[]>>(`/session/${sessionId}/elements`, {
1055
- method: 'POST', body: JSON.stringify(selector),
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 deadline = Date.now() + Math.max(0, step.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS);
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 payload = await appiumRequest<AppiumValueResponse<Record<string, string>[]>>(`/session/${sessionId}/elements`, {
1066
- method: 'POST', body: JSON.stringify(selector),
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 appiumRequest<AppiumValueResponse<string>>(`/session/${sessionId}/element/${elementId}/text`);
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;