android-midscene-automation 0.1.26 → 0.1.28

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/FAQ.md +14 -0
  3. package/README.md +39 -3
  4. package/bin/android-midscene-automation.js +71 -1
  5. package/package.json +7 -2
  6. package/remote-agent/index.ts +2 -11
  7. package/server/appium-recorder/ai-recognition.ts +67 -0
  8. package/server/appium-recorder/appium-runner.ts +335 -52
  9. package/server/appium-recorder/report.ts +297 -16
  10. package/server/appium-recorder/repository.ts +31 -4
  11. package/server/appium-recorder/routes.ts +31 -32
  12. package/server/appium-recorder/screenshot.ts +21 -0
  13. package/server/appium-recorder/script-export.ts +67 -0
  14. package/server/appium-recorder/tree-dump.ts +22 -0
  15. package/server/appium-recorder/visual-change.ts +184 -0
  16. package/server/config.ts +10 -0
  17. package/server/http-api.ts +4 -5
  18. package/src/App.vue +14 -7
  19. package/src/api.ts +1 -1
  20. package/src/appium-recorder/AppiumPage.vue +658 -162
  21. package/src/appium-recorder/ai-recognition.ts +16 -0
  22. package/src/appium-recorder/api.ts +20 -3
  23. package/src/appium-recorder/components/AiRecognitionTestDialog.vue +71 -0
  24. package/src/appium-recorder/components/FlowActionMenu.vue +206 -0
  25. package/src/appium-recorder/components/FlowCanvas.vue +16 -1
  26. package/src/appium-recorder/components/FlowNodeCard.vue +23 -37
  27. package/src/appium-recorder/components/FlowStepEditor.vue +152 -25
  28. package/src/appium-recorder/components/LongPressSettings.vue +58 -0
  29. package/src/appium-recorder/components/NestedConditionBranches.vue +55 -35
  30. package/src/appium-recorder/components/NewScriptDialog.vue +27 -0
  31. package/src/appium-recorder/components/NodeDetail.vue +2 -0
  32. package/src/appium-recorder/components/RecordedSteps.vue +24 -4
  33. package/src/appium-recorder/components/StageLogSettings.vue +17 -0
  34. package/src/appium-recorder/components/TextClickSettings.vue +19 -0
  35. package/src/appium-recorder/components/VisualChangeDialog.vue +151 -0
  36. package/src/appium-recorder/flow-copy.ts +47 -1
  37. package/src/appium-recorder/flow-graph.ts +147 -64
  38. package/src/appium-recorder/flow-labels.ts +35 -6
  39. package/src/appium-recorder/flow-normalize.ts +76 -0
  40. package/src/appium-recorder/flow-remove.ts +69 -0
  41. package/src/appium-recorder/long-press.ts +27 -0
  42. package/src/appium-recorder/native-control-state.ts +26 -0
  43. package/src/appium-recorder/stage-log.ts +21 -0
  44. package/src/appium-recorder/text-click.ts +9 -0
  45. package/src/appium-recorder/tree.ts +3 -0
  46. package/src/appium-recorder/types.ts +34 -2
  47. package/src/appium-recorder/visual-change-pairs.ts +147 -0
  48. package/src/appium-recorder/visual-change.ts +75 -0
  49. package/src/components/config/AppiumConfigPanel.vue +48 -0
  50. package/src/components/config/BaseConfigPanel.vue +90 -0
  51. package/src/components/config/MidsceneConfigPanel.vue +204 -0
  52. package/src/components/device/DevicePreviewPanel.vue +148 -4
  53. package/src/pages/ConfigPage.vue +107 -265
  54. package/src/style.css +43 -4
  55. package/src/types.ts +2 -0
  56. package/src/ui-refresh.css +5 -0
@@ -1,12 +1,20 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { adbScreenshotBase64 } from './screenshot';
3
+ import { recognizeDeviceScreen } from './ai-recognition';
4
+ import { formatStageLog } from '../../src/appium-recorder/stage-log';
5
+ import { textClickSelector } from '../../src/appium-recorder/text-click';
2
6
  import { AsyncLocalStorage } from 'node:async_hooks';
3
7
  import { mkdir, writeFile } from 'node:fs/promises';
4
8
  import { join } from 'node:path';
5
9
  import { getAppiumRecordedScript, type AppiumRecordedScriptRecord, type AppiumRecordedStepRecord } from './repository';
6
10
  import { appDataPath } from '../paths';
7
11
  import { ensureAndroidSdkAvailable, getAdbCommand } from '../android-sdk';
8
- import { createAppiumReplayReport, type AppiumReplayFrame } from './report';
12
+ import { createAppiumReplayReport, type AppiumReplayFrame, type AppiumReplayVisualCheck } from './report';
9
13
  import { startManagedAppiumServer, usesManagedAppiumServer } from './managed-appium';
14
+ import { compareVisualChangeFrames, type VisualChangeRegion } from './visual-change';
15
+ import { longPressMode, validateLongPress } from '../../src/appium-recorder/long-press';
16
+ import { defaultFlowKind, flowBranchLabel } from '../../src/appium-recorder/flow-labels';
17
+ import { isNativeStateCondition, nativeControlName, nativeControlType, parseNativeBoolean } from '../../src/appium-recorder/native-control-state';
10
18
 
11
19
  type AppiumSessionResponse = {
12
20
  value?: {
@@ -34,11 +42,21 @@ class ReplayStoppedError extends Error {
34
42
  }
35
43
  }
36
44
 
45
+ type PendingVisualChangeCheck = {
46
+ step: AppiumRecordedStepRecord;
47
+ nodeNumber: number;
48
+ scriptName: string;
49
+ };
50
+
37
51
  const replayContext = new AsyncLocalStorage<{
38
52
  signal?: AbortSignal;
39
53
  appiumLog: (line: string) => void;
40
54
  serverUrl: string;
55
+ deviceId: string;
41
56
  frames: AppiumReplayFrame[];
57
+ visualChecks: AppiumReplayVisualCheck[];
58
+ pendingVisualChecks: PendingVisualChangeCheck[];
59
+ softFailureCount: number;
42
60
  }>();
43
61
 
44
62
  const appiumServerUrl = () => replayContext.getStore()?.serverUrl || configuredAppiumServerUrl();
@@ -60,6 +78,11 @@ function errorDetail(error: unknown) {
60
78
  return error.message;
61
79
  }
62
80
 
81
+ function isTimeoutError(error: unknown) {
82
+ const detail = errorDetail(error).toLowerCase();
83
+ return detail.includes('timeout') || detail.includes('timed out');
84
+ }
85
+
63
86
  function isStaleElementError(error: unknown) {
64
87
  const detail = errorDetail(error);
65
88
  return detail.includes('stale element reference') || detail.includes('does not exist in DOM anymore');
@@ -254,6 +277,9 @@ async function appiumRequest<T>(
254
277
  throw new ReplayStoppedError();
255
278
  }
256
279
  context?.appiumLog(`[Appium] 异常:${method} ${path}(${errorDetail(error)})`);
280
+ if (isTimeoutError(error)) {
281
+ throw new Error(`Appium 请求超时 ${requestUrl},${errorDetail(error)}`);
282
+ }
257
283
  throw new Error(`无法连接 Appium 服务 ${requestUrl},${errorDetail(error)}`);
258
284
  }
259
285
 
@@ -284,6 +310,7 @@ async function appiumRequest<T>(
284
310
  }
285
311
 
286
312
  function replayFrameSelector(step: AppiumRecordedStepRecord) {
313
+ if (step.type === 'textClick') return `${step.flow?.textMatch === 'exact' ? '精准匹配' : '模糊匹配'}:${step.value || ''}`;
287
314
  const selector = step.selector;
288
315
  if (!selector) return '-';
289
316
  if (selector.strategy === 'bounds') return `bounds (${selector.centerX ?? '-'}, ${selector.centerY ?? '-'})`;
@@ -291,15 +318,16 @@ function replayFrameSelector(step: AppiumRecordedStepRecord) {
291
318
  }
292
319
 
293
320
  async function captureReplayFrame(
294
- sessionId: string,
321
+ _sessionId: string,
295
322
  step: AppiumRecordedStepRecord,
296
323
  nodeNumber: number,
297
324
  scriptName: string,
298
325
  phase: AppiumReplayFrame['phase'],
299
326
  status: string,
327
+ imageBase64?: string,
300
328
  ) {
301
329
  const context = replayContext.getStore();
302
- if (!context || !sessionId) return;
330
+ if (!context || !context.deviceId) return;
303
331
  const appendFrame = (imageBase64: string) => {
304
332
  context.frames.push({
305
333
  sequence: context.frames.length + 1,
@@ -308,6 +336,7 @@ async function captureReplayFrame(
308
336
  nodeNumber,
309
337
  nodeLabel: step.label,
310
338
  nodeType: step.type,
339
+ logContent: step.type === 'log' && phase === 'after' ? formatStageLog(step) : undefined,
311
340
  note: step.note || '',
312
341
  selector: replayFrameSelector(step),
313
342
  phase,
@@ -324,14 +353,8 @@ async function captureReplayFrame(
324
353
  }
325
354
 
326
355
  try {
327
- const payload = await appiumRequest<AppiumValueResponse<string>>(
328
- `/session/${sessionId}/screenshot`,
329
- undefined,
330
- { ignoreAbort: true, timeoutMs: 5000 },
331
- );
332
- const imageBase64 = typeof payload.value === 'string' ? payload.value : '';
333
- if (!imageBase64) throw new Error('Appium 未返回截图内容');
334
- appendFrame(imageBase64);
356
+ // Report screenshots use ADB so a slow capture cannot block Appium's command queue.
357
+ appendFrame(imageBase64 || await adbScreenshotBase64(context.deviceId));
335
358
  } catch (error) {
336
359
  context.appiumLog(`[节点 ${nodeNumber}] 截图失败:${errorDetail(error)}`);
337
360
  }
@@ -443,8 +466,7 @@ async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord)
443
466
  let lastError: unknown;
444
467
  while (Date.now() - startedAt <= timeoutMs) {
445
468
  try {
446
- await findElement(sessionId, step);
447
- return;
469
+ return await findElement(sessionId, step);
448
470
  } catch (error) {
449
471
  throwIfReplayStopped(error);
450
472
  lastError = error;
@@ -483,16 +505,183 @@ async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRec
483
505
  throw new Error(`${step.label} 等待消失超时`);
484
506
  }
485
507
 
486
- async function saveScreenshot(sessionId: string) {
487
- const payload = await appiumRequest<AppiumValueResponse<string>>(`/session/${sessionId}/screenshot`);
488
- if (!payload.value) throw new Error('Appium 未返回截图数据');
508
+ async function saveScreenshot(deviceId: string) {
489
509
  const dir = appDataPath('.midscene-app', 'screenshots');
490
510
  await mkdir(dir, { recursive: true });
491
511
  const file = join(dir, `appium-${Date.now()}.png`);
492
- await writeFile(file, Buffer.from(payload.value, 'base64'));
512
+ await writeFile(file, Buffer.from(await adbScreenshotBase64(deviceId), 'base64'));
513
+ }
514
+
515
+ type RunStepMeta = {
516
+ nodeNumber: number;
517
+ scriptName: string;
518
+ };
519
+
520
+ type RunStepResult = string | {
521
+ message?: string;
522
+ softFailed?: boolean;
523
+ };
524
+
525
+ function completedStepStatus(step: AppiumRecordedStepRecord, softFailed: boolean) {
526
+ if (step.type === 'visualChange') {
527
+ if (step.visualChange?.role === 'start') return '已记录基准帧';
528
+ if (step.visualChange?.role === 'end') return '已记录对比帧';
529
+ }
530
+ return softFailed ? '视觉变化未达预期' : '成功';
531
+ }
532
+
533
+ function visualChangeNumber(value: unknown, fallback: number, min: number, max = Number.POSITIVE_INFINITY) {
534
+ const number = Number(value);
535
+ if (!Number.isFinite(number)) return fallback;
536
+ return Math.min(max, Math.max(min, number));
537
+ }
538
+
539
+ function visualChangeRegion(step: AppiumRecordedStepRecord): VisualChangeRegion {
540
+ const config = step.visualChange;
541
+ if (!config?.region) throw new Error(`${step.label} 缺少检测区域`);
542
+ return {
543
+ x: Math.max(0, Math.round(Number(config.region.x) || 0)),
544
+ y: Math.max(0, Math.round(Number(config.region.y) || 0)),
545
+ width: Math.max(1, Math.round(Number(config.region.width) || 1)),
546
+ height: Math.max(1, Math.round(Number(config.region.height) || 1)),
547
+ };
493
548
  }
494
549
 
495
- async function runStep(sessionId: string, deviceId: string, step: AppiumRecordedStepRecord) {
550
+ function findVisualFrame(
551
+ frames: AppiumReplayFrame[],
552
+ nodeId: string,
553
+ scriptName: string,
554
+ preferredPhase: AppiumReplayFrame['phase'],
555
+ ) {
556
+ const matches = frames.filter((frame) => frame.nodeId === nodeId && frame.scriptName === scriptName);
557
+ if (!matches.length) return undefined;
558
+ const preferred = preferredPhase === 'before'
559
+ ? matches.find((frame) => frame.phase === preferredPhase)
560
+ : [...matches].reverse().find((frame) => frame.phase === preferredPhase);
561
+ return preferred || (preferredPhase === 'before' ? matches[0] : matches[matches.length - 1]);
562
+ }
563
+
564
+ const EMPTY_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
565
+
566
+ function registerVisualChangeStep(
567
+ step: AppiumRecordedStepRecord,
568
+ meta: RunStepMeta,
569
+ ): RunStepResult {
570
+ const config = step.visualChange;
571
+ if (!config) throw new Error(`${step.label} 缺少画面变化检测配置`);
572
+ if (config.role === 'start') {
573
+ return {
574
+ message: `已记录画面变化基准点:${config.pairLabel || step.label}`,
575
+ };
576
+ }
577
+ const context = replayContext.getStore();
578
+ const endStep = {
579
+ ...step,
580
+ visualChange: {
581
+ ...config,
582
+ endStepId: config.endStepId || step.id,
583
+ },
584
+ };
585
+ context?.pendingVisualChecks.push({ step: endStep, nodeNumber: meta.nodeNumber, scriptName: meta.scriptName });
586
+ return {
587
+ message: `已记录画面变化对比点:${config.pairLabel || step.label}`,
588
+ };
589
+ }
590
+
591
+ function appendVisualCheckFailure(lines: string[], check: PendingVisualChangeCheck, message: string) {
592
+ const context = replayContext.getStore();
593
+ if (context) {
594
+ const config = check.step.visualChange;
595
+ context.softFailureCount += 1;
596
+ context.visualChecks.push({
597
+ nodeId: check.step.id,
598
+ nodeNumber: check.nodeNumber,
599
+ nodeLabel: check.step.label,
600
+ status: 'failed',
601
+ message,
602
+ region: config?.region ? visualChangeRegion(check.step) : { x: 0, y: 0, width: 1, height: 1 },
603
+ durationMs: 0,
604
+ intervalMs: 0,
605
+ sampleCount: 0,
606
+ changeRatioThreshold: visualChangeNumber(config?.changeRatioThreshold, 2, 0.01),
607
+ maxChangeRatio: 0,
608
+ baselineBase64: EMPTY_PNG_BASE64,
609
+ comparisonBase64: EMPTY_PNG_BASE64,
610
+ diffBase64: EMPTY_PNG_BASE64,
611
+ startNodeId: config?.startStepId,
612
+ endNodeId: config?.endStepId,
613
+ });
614
+ }
615
+ lines.push(`[节点 ${check.nodeNumber}] 视觉变化检测异常:${message}(继续执行)`);
616
+ }
617
+
618
+ function processPendingVisualChecks(lines: string[]) {
619
+ const context = replayContext.getStore();
620
+ if (!context || !context.pendingVisualChecks.length) return;
621
+
622
+ // Visual checks compare captured replay frames after the path finishes, so
623
+ // they never block or mutate the device state for downstream nodes.
624
+ context.pendingVisualChecks.forEach((check) => {
625
+ const config = check.step.visualChange;
626
+ if (!config?.startStepId || !config.endStepId) {
627
+ appendVisualCheckFailure(lines, check, `${check.step.label} 缺少开始节点或结束节点`);
628
+ return;
629
+ }
630
+ const baselineFrame = findVisualFrame(context.frames, config.startStepId, check.scriptName, 'after');
631
+ const comparisonFrame = findVisualFrame(context.frames, config.endStepId, check.scriptName, 'after');
632
+ if (!baselineFrame || !comparisonFrame) {
633
+ const missing = [
634
+ baselineFrame ? '' : '开始节点截图',
635
+ comparisonFrame ? '' : '结束节点截图',
636
+ ].filter(Boolean).join('、');
637
+ appendVisualCheckFailure(lines, check, `${check.step.label} 未捕获到${missing}`);
638
+ return;
639
+ }
640
+ let result: ReturnType<typeof compareVisualChangeFrames>;
641
+ try {
642
+ result = compareVisualChangeFrames({
643
+ baselineScreenshot: Buffer.from(baselineFrame.imageBase64, 'base64'),
644
+ comparisonScreenshot: Buffer.from(comparisonFrame.imageBase64, 'base64'),
645
+ region: visualChangeRegion(check.step),
646
+ changeRatioThreshold: visualChangeNumber(config.changeRatioThreshold, 2, 0.01),
647
+ pixelmatchThreshold: visualChangeNumber(config.pixelmatchThreshold, 0.1, 0, 1),
648
+ });
649
+ } catch (error) {
650
+ appendVisualCheckFailure(lines, check, `${check.step.label} 对比失败:${errorDetail(error)}`);
651
+ return;
652
+ }
653
+ context.visualChecks.push({
654
+ nodeId: check.step.id,
655
+ nodeNumber: check.nodeNumber,
656
+ nodeLabel: check.step.label,
657
+ status: result.passed ? 'passed' : 'failed',
658
+ message: result.message,
659
+ region: result.region,
660
+ durationMs: result.durationMs,
661
+ intervalMs: result.intervalMs,
662
+ sampleCount: result.sampleCount,
663
+ changeRatioThreshold: result.changeRatioThreshold,
664
+ maxChangeRatio: result.maxChangeRatio,
665
+ baselineBase64: result.baselineBase64,
666
+ comparisonBase64: result.comparisonBase64,
667
+ diffBase64: result.diffBase64,
668
+ startNodeId: config.startStepId,
669
+ startNodeLabel: `${baselineFrame.nodeNumber}. ${baselineFrame.nodeLabel}`,
670
+ endNodeId: config.endStepId,
671
+ endNodeLabel: `${comparisonFrame.nodeNumber}. ${comparisonFrame.nodeLabel}`,
672
+ });
673
+ if (!result.passed) context.softFailureCount += 1;
674
+ lines.push(`[节点 ${check.nodeNumber}] 视觉变化检测${result.passed ? '有变化' : '无明显变化'}:${result.message}${result.passed ? '' : '(继续执行)'}`);
675
+ });
676
+ context.pendingVisualChecks = [];
677
+ }
678
+
679
+ async function runStep(
680
+ sessionId: string,
681
+ deviceId: string,
682
+ step: AppiumRecordedStepRecord,
683
+ meta: RunStepMeta,
684
+ ): Promise<RunStepResult | void> {
496
685
  throwIfReplayStopped();
497
686
  if (step.type === 'delay') {
498
687
  await wait(Math.max(0, step.timeoutMs || 1000));
@@ -528,10 +717,19 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
528
717
  }
529
718
 
530
719
  if (step.type === 'screenshot') {
531
- await saveScreenshot(sessionId);
720
+ await saveScreenshot(deviceId);
532
721
  return;
533
722
  }
534
723
 
724
+ if (step.type === 'visualChange') {
725
+ return registerVisualChangeStep(step, meta);
726
+ }
727
+
728
+ if (step.type === 'noop') {
729
+ return;
730
+ }
731
+ if (step.type === 'log') return formatStageLog(step);
732
+
535
733
  if (step.type === 'coordinateTap') {
536
734
  await tapFallback(deviceId, step);
537
735
  return;
@@ -544,13 +742,21 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
544
742
  }
545
743
 
546
744
  if (step.type === 'longPress') {
547
- if (step.fallback?.strategy !== 'bounds' || !Number.isFinite(step.fallback.centerX) || !Number.isFinite(step.fallback.centerY)) {
548
- throw new Error(`${step.label} 缺少长按坐标`);
745
+ const error = validateLongPress(step);
746
+ if (error) throw new Error(`${step.label}:${error}`);
747
+ const duration = step.timeoutMs ?? 800;
748
+ if (longPressMode(step) === 'element') {
749
+ const elementId = await findElement(sessionId, step);
750
+ await appiumRequest(`/session/${sessionId}/execute/sync`, {
751
+ method: 'POST',
752
+ body: JSON.stringify({ script: 'mobile: longClickGesture', args: [{ elementId, duration }] }),
753
+ });
754
+ return `已长按元素 ${duration}ms`;
549
755
  }
550
- const x = Number(step.fallback.centerX);
551
- const y = Number(step.fallback.centerY);
552
- await adbSwipe(deviceId, { startX: x, startY: y, endX: x, endY: y, duration: step.timeoutMs || 800 });
553
- return;
756
+ const x = Number(step.fallback!.centerX);
757
+ const y = Number(step.fallback!.centerY);
758
+ await adbSwipe(deviceId, { startX: x, startY: y, endX: x, endY: y, duration });
759
+ return `已长按坐标 ${x},${y} ${duration}ms`;
554
760
  }
555
761
 
556
762
  if (step.type === 'pinch') {
@@ -681,7 +887,48 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
681
887
  }
682
888
  }
683
889
 
890
+ async function readNativeControlState(sessionId: string, step: AppiumRecordedStepRecord) {
891
+ if (!isNativeStateCondition(step)) throw new Error('不是原生控件状态判断节点');
892
+ const elementId = await waitForElement(sessionId, step);
893
+ const attribute = async (name: string) => (
894
+ await appiumRequest<AppiumValueResponse<unknown>>(`/session/${sessionId}/element/${elementId}/attribute/${name}`)
895
+ ).value;
896
+ const className = await attribute('className');
897
+ if (nativeControlType(className) !== step.type) {
898
+ throw new Error(`${step.label} 目标不是原生 ${nativeControlName(step.type)},实际类型:${String(className ?? '未知')}`);
899
+ }
900
+ if (parseNativeBoolean(await attribute('checkable')) !== true) {
901
+ throw new Error(`${step.label} 目标不支持原生勾选状态(checkable 不为 true)`);
902
+ }
903
+ const checked = parseNativeBoolean(await attribute('checked'));
904
+ if (checked === undefined) throw new Error(`${step.label} 无法读取有效的 checked 属性`);
905
+ return checked;
906
+ }
907
+
684
908
  async function evaluateCondition(sessionId: string, deviceId: string, step: AppiumRecordedStepRecord) {
909
+ if (step.type === 'textClick') {
910
+ const selector = textClickSelector(step);
911
+ const deadline = Date.now() + Math.max(0, step.timeoutMs ?? 10000);
912
+ // 查询空结果才是未匹配;通信错误、重复匹配和点击失败走现有异常处理。
913
+ do {
914
+ const payload = await appiumRequest<AppiumValueResponse<Record<string, string>[]>>(`/session/${sessionId}/elements`, {
915
+ method: 'POST', body: JSON.stringify(selector),
916
+ });
917
+ if (!Array.isArray(payload.value)) throw new Error('文字点击查询返回了无效结果');
918
+ if (payload.value.length > 1) throw new Error(`匹配到 ${payload.value.length} 个文字元素,请使用更精确的文字`);
919
+ if (payload.value.length === 1) {
920
+ const element = payload.value[0]!;
921
+ const id = element[ELEMENT_KEY] || element.ELEMENT;
922
+ if (!id) throw new Error('文字点击查询缺少元素 ID');
923
+ await appiumRequest(`/session/${sessionId}/element/${id}/click`, { method: 'POST', body: '{}' });
924
+ return true;
925
+ }
926
+ if (Date.now() >= deadline) return false;
927
+ await wait(Math.max(0, Math.min(500, deadline - Date.now())));
928
+ } while (true);
929
+ }
930
+ // 未找到控件、类型错误和属性读取异常不等于未勾选,必须交给回放错误处理。
931
+ if (isNativeStateCondition(step)) return readNativeControlState(sessionId, step);
685
932
  try {
686
933
  if (step.type === 'waitActivity') {
687
934
  await waitForActivity(deviceId, step);
@@ -710,7 +957,7 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
710
957
 
711
958
  function hasFlowSteps(steps: AppiumRecordedStepRecord[]) {
712
959
  return steps.some((step) => (
713
- step.flow?.nodeKind === 'condition'
960
+ defaultFlowKind(step) === 'condition'
714
961
  || Boolean(step.flow?.yesTargetId)
715
962
  || Boolean(step.flow?.noTargetId)
716
963
  || Boolean(step.flow?.successTargetId)
@@ -797,6 +1044,20 @@ async function replayLinkedScript(
797
1044
  lines.push(`连接脚本完成:${linkedScript.name}`);
798
1045
  }
799
1046
 
1047
+ function appendRunStepResult(lines: string[], nodeNumber: number, result: RunStepResult | void) {
1048
+ if (!result) return false;
1049
+ if (typeof result === 'string') {
1050
+ if (result) lines.push(`[节点 ${nodeNumber}] 结果:${result}`);
1051
+ return false;
1052
+ }
1053
+ if (result.softFailed) {
1054
+ lines.push(`[节点 ${nodeNumber}] 视觉变化未达预期:${result.message || '未检测到明显变化'}(继续执行)`);
1055
+ return true;
1056
+ }
1057
+ if (result.message) lines.push(`[节点 ${nodeNumber}] 结果:${result.message}`);
1058
+ return false;
1059
+ }
1060
+
800
1061
  async function replayLinearSteps(
801
1062
  sessionId: string,
802
1063
  deviceId: string,
@@ -814,25 +1075,24 @@ async function replayLinearSteps(
814
1075
  lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
815
1076
  await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'before', '执行前');
816
1077
  try {
1078
+ let softFailed = false;
817
1079
  if (step.type === 'runScript') {
818
1080
  await replayLinkedScript(sessionId, deviceId, step, lines, stack);
819
1081
  } else {
820
- const result = await runStep(sessionId, deviceId, step);
821
- if (result) lines.push(`[节点 ${index + 1}] 结果:${result}`);
1082
+ const result = await runStep(sessionId, deviceId, step, {
1083
+ nodeNumber: index + 1,
1084
+ scriptName: options.scriptName || '',
1085
+ });
1086
+ softFailed = appendRunStepResult(lines, index + 1, result);
822
1087
  }
823
- lines.push(`[节点 ${index + 1}] 完成:${step.label}`);
824
- await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', '成功');
1088
+ lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
1089
+ await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
825
1090
  } catch (error) {
826
1091
  if (isReplayStopped(error)) {
827
1092
  lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
828
1093
  await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
829
1094
  throw new ReplayStoppedError();
830
1095
  }
831
- if (step.optional) {
832
- lines.push(`[节点 ${index + 1}] 跳过:${errorDetail(error)}`);
833
- await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '已跳过');
834
- continue;
835
- }
836
1096
  lines.push(`[节点 ${index + 1}] 失败:${errorDetail(error)}`);
837
1097
  await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '失败');
838
1098
  await appendStepDiagnostics(lines, deviceId, step);
@@ -884,23 +1144,33 @@ async function replayFlowSteps(
884
1144
  continue;
885
1145
  }
886
1146
  visitedPath.push(step.label);
887
- const nodeKind = step.flow?.nodeKind || (step.type === 'assertExists' || step.type === 'assertText' ? 'assertion' : 'action');
1147
+ const nodeKind = defaultFlowKind(step);
888
1148
  lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
889
1149
  await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'before', '执行前');
890
1150
 
891
1151
  if (nodeKind === 'condition') {
892
1152
  try {
893
- const matched = await evaluateCondition(sessionId, deviceId, step);
1153
+ // AI 节点使用本次执行前截图,并将同一张图片和识别依据写入结果帧。
1154
+ const beforeFrame = replayContext.getStore()?.frames.at(-1);
1155
+ const recognition = step.type === 'aiRecognition' ? await recognizeDeviceScreen({
1156
+ deviceId, prompt: step.value, timeoutMs: step.timeoutMs,
1157
+ signal: replayContext.getStore()?.signal,
1158
+ imageBase64: beforeFrame?.nodeId === step.id && beforeFrame.phase === 'before' ? beforeFrame.imageBase64 : undefined,
1159
+ }) : undefined;
1160
+ const matched = recognition ? recognition.result : await evaluateCondition(sessionId, deviceId, step);
1161
+ if (recognition) lines.push(`[节点 ${index + 1}] AI 识别:${recognition.result},耗时 ${recognition.durationMs}ms${recognition.reason ? `,依据:${recognition.reason}` : ''}`);
894
1162
  const branchTargetId = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
895
1163
  const targetId = branchTargetId || step.flow?.successTargetId || '';
896
- lines.push(`[节点 ${index + 1}] 判断:${matched ? '是' : '否'}${targetId ? `,进入 ${targetId}` : ',流程结束'}`);
1164
+ const branchLabel = flowBranchLabel(step, matched ? 'yes' : 'no');
1165
+ lines.push(`[节点 ${index + 1}] 判断:${branchLabel}${isNativeStateCondition(step) ? `(checked=${matched})` : ''}${targetId ? `,进入 ${targetId}` : ',流程结束'}`);
897
1166
  await captureReplayFrame(
898
1167
  sessionId,
899
1168
  step,
900
1169
  index + 1,
901
1170
  options.scriptName || '',
902
1171
  'after',
903
- `判断:${matched ? '是' : '否'}`,
1172
+ `判断:${branchLabel}${recognition?.reason ? `;${recognition.reason}` : ''}`,
1173
+ recognition?.imageBase64,
904
1174
  );
905
1175
  index = targetId ? idToIndex.get(targetId) : undefined;
906
1176
  } catch (error) {
@@ -909,20 +1179,27 @@ async function replayFlowSteps(
909
1179
  await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
910
1180
  throw new ReplayStoppedError();
911
1181
  }
1182
+ lines.push(`[节点 ${index + 1}] 失败:${errorDetail(error)}`);
1183
+ await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '失败');
1184
+ await appendStepDiagnostics(lines, deviceId, step);
912
1185
  throw error;
913
1186
  }
914
1187
  continue;
915
1188
  }
916
1189
 
917
1190
  try {
1191
+ let softFailed = false;
918
1192
  if (step.type === 'runScript') {
919
1193
  await replayLinkedScript(sessionId, deviceId, step, lines, stack);
920
1194
  } else {
921
- const result = await runStep(sessionId, deviceId, step);
922
- if (result) lines.push(`[节点 ${index + 1}] 结果:${result}`);
1195
+ const result = await runStep(sessionId, deviceId, step, {
1196
+ nodeNumber: index + 1,
1197
+ scriptName: options.scriptName || '',
1198
+ });
1199
+ softFailed = appendRunStepResult(lines, index + 1, result);
923
1200
  }
924
- lines.push(`[节点 ${index + 1}] 完成:${step.label}`);
925
- await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', '成功');
1201
+ lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
1202
+ await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
926
1203
  index = nextIndexAfterStep(index, step);
927
1204
  } catch (error) {
928
1205
  if (isReplayStopped(error)) {
@@ -930,12 +1207,6 @@ async function replayFlowSteps(
930
1207
  await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
931
1208
  throw new ReplayStoppedError();
932
1209
  }
933
- if (step.optional) {
934
- lines.push(`[节点 ${index + 1}] 跳过:${errorDetail(error)}`);
935
- await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'error', '已跳过');
936
- index = nextIndexAfterStep(index, step);
937
- continue;
938
- }
939
1210
  const failureTarget = step.flow?.failureTargetId;
940
1211
  if (failureTarget && idToIndex.has(failureTarget)) {
941
1212
  lines.push(`[节点 ${index + 1}] 失败分支:${errorDetail(error)}`);
@@ -997,7 +1268,7 @@ async function replayScriptSteps(
997
1268
  try {
998
1269
  await replayLinkedScript(sessionId, deviceId, step, lines, stack);
999
1270
  lines.push(`[节点 ${nodeNumber}] 完成:${step.label}`);
1000
- await captureReplayFrame(sessionId, step, nodeNumber, options.scriptName || '', 'after', '成功');
1271
+ await captureReplayFrame(sessionId, step, nodeNumber, options.scriptName || '', 'after', completedStepStatus(step, false));
1001
1272
  } catch (error) {
1002
1273
  if (isReplayStopped(error)) {
1003
1274
  lines.push(`[节点 ${nodeNumber}] 已终止:${step.label}`);
@@ -1033,7 +1304,11 @@ export async function replayAppiumScript(
1033
1304
  signal,
1034
1305
  appiumLog: (line) => lines.push(line),
1035
1306
  serverUrl: configuredAppiumServerUrl(),
1307
+ deviceId: targetDeviceId,
1036
1308
  frames: [] as AppiumReplayFrame[],
1309
+ visualChecks: [] as AppiumReplayVisualCheck[],
1310
+ pendingVisualChecks: [] as PendingVisualChangeCheck[],
1311
+ softFailureCount: 0,
1037
1312
  };
1038
1313
  return replayContext.run(context, async () => {
1039
1314
  lines.push(
@@ -1071,8 +1346,14 @@ export async function replayAppiumScript(
1071
1346
  await replayScriptSteps(sessionId, targetDeviceId, script.steps, lines, [script.id], {
1072
1347
  scriptName: script.name,
1073
1348
  });
1074
- lines.push('回放完成');
1075
- success = true;
1349
+ processPendingVisualChecks(lines);
1350
+ if (context.softFailureCount > 0) {
1351
+ lines.push(`回放完成,但存在 ${context.softFailureCount} 个视觉变化未达预期节点`);
1352
+ success = false;
1353
+ } else {
1354
+ lines.push('回放完成');
1355
+ success = true;
1356
+ }
1076
1357
  } catch (error) {
1077
1358
  stopped = isReplayStopped(error);
1078
1359
  const detail = errorDetail(error);
@@ -1126,6 +1407,7 @@ export async function replayAppiumScript(
1126
1407
  startedAt,
1127
1408
  completedAt,
1128
1409
  frames: context.frames,
1410
+ visualChecks: context.visualChecks,
1129
1411
  });
1130
1412
  reportPath = report.filePath;
1131
1413
  reportId = report.id;
@@ -1145,6 +1427,7 @@ export async function replayAppiumScript(
1145
1427
  reportId,
1146
1428
  logPath,
1147
1429
  htmlReportPath,
1430
+ softFailureCount: context.softFailureCount,
1148
1431
  };
1149
1432
  });
1150
1433
  }