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.
@@ -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,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 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)) {
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
- 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;
549
674
  try {
550
- return await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
675
+ return await findElement(sessionId, step, remaining);
551
676
  } catch (error) {
552
677
  throwIfReplayStopped(error);
553
678
  lastError = error;
554
- await wait(500);
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
- 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;
564
694
  try {
565
- return await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
695
+ return await findElement(sessionId, step, remaining);
566
696
  } catch (error) {
567
697
  throwIfReplayStopped(error);
568
- await wait(250);
698
+ const pauseMs = Math.min(250, timeoutMs - (Date.now() - startedAt));
699
+ if (pauseMs > 0) await wait(pauseMs);
569
700
  }
570
701
  }
571
- 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
+ }
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
- 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;
578
725
  try {
579
- await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
726
+ await findElement(sessionId, step, remaining);
580
727
  } catch (error) {
581
728
  throwIfReplayStopped(error);
582
729
  return;
583
730
  }
584
- await wait(500);
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 appiumRequest<AppiumValueResponse<unknown>>(`/session/${sessionId}/element/${elementId}/${endpoint}`);
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 appiumRequest(`/session/${sessionId}/appium/device/press_keycode`, {
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
- const elementId = await findElement(sessionId, step);
872
- await appiumRequest(`/session/${sessionId}/execute/sync`, {
873
- method: 'POST',
874
- body: JSON.stringify({ script: 'mobile: longClickGesture', args: [{ elementId, duration }] }),
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 appiumRequest(`/session/${sessionId}/execute/sync`, {
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 elementId = await findOptionalElement(sessionId, step);
910
- if (!elementId) return '未出现,已跳过';
911
- await appiumRequest(`/session/${sessionId}/element/${elementId}/click`, {
912
- method: 'POST',
913
- body: JSON.stringify({}),
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 elementId = await findOptionalElement(sessionId, step);
920
- if (!elementId) return '未出现,已跳过';
921
- 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`, {
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 elementId = await findOptionalElement(sessionId, step);
930
- if (!elementId) return '未出现,已跳过';
931
- 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`, {
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 elementId = await findOptionalElement(sessionId, step);
940
- if (!elementId) return '未出现,已跳过';
941
- await appiumRequest(`/session/${sessionId}/appium/device/press_keycode`, {
942
- method: 'POST',
943
- body: JSON.stringify({ keycode: 4 }),
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 appiumRequest<AppiumValueResponse<string>>(`/session/${sessionId}/element/${elementId}/text`);
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 appiumRequest(`/session/${sessionId}/element/${elementId}/click`, {
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 appiumRequest(`/session/${sessionId}/element/${elementId}/value`, {
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 appiumRequest(`/session/${sessionId}/element/${elementId}/clear`, {
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 appiumRequest<AppiumValueResponse<unknown>>(`/session/${sessionId}/element/${elementId}/attribute/${name}`)
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 appiumRequest<AppiumValueResponse<Record<string, string>[]>>(`/session/${sessionId}/elements`, {
1036
- method: 'POST', body: JSON.stringify(selector),
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 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
+ };
1044
1211
  // 查询空结果才是未匹配;通信错误、重复匹配和点击失败走现有异常处理。
1045
1212
  do {
1046
- const payload = await appiumRequest<AppiumValueResponse<Record<string, string>[]>>(`/session/${sessionId}/elements`, {
1047
- method: 'POST', body: JSON.stringify(selector),
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 appiumRequest(`/session/${sessionId}/element/${id}/click`, { method: 'POST', body: '{}' });
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 appiumRequest<AppiumValueResponse<string>>(`/session/${sessionId}/element/${elementId}/text`);
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;