android-midscene-automation 0.1.30 → 0.1.32

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 (43) hide show
  1. package/CHANGELOG.md +16 -26
  2. package/README.md +16 -26
  3. package/bin/sync-readme-docs.mjs +49 -0
  4. package/index.html +1 -1
  5. package/package.json +8 -8
  6. package/remote-agent/index.ts +1 -1
  7. package/server/appium-recorder/appium-runner.ts +97 -43
  8. package/server/appium-recorder/bounded-loop.ts +118 -0
  9. package/server/appium-recorder/history-capture.ts +34 -0
  10. package/server/appium-recorder/report.ts +1 -1
  11. package/server/appium-recorder/repository.ts +9 -0
  12. package/server/appium-recorder/routes.ts +29 -5
  13. package/server/appium-recorder/run-history.ts +34 -0
  14. package/src/appium-recorder/AppiumPage.vue +114 -54
  15. package/src/appium-recorder/RunHistoryDialog.vue +153 -0
  16. package/src/appium-recorder/bounded-loop.ts +66 -0
  17. package/src/appium-recorder/components/BreakLoopSettings.vue +29 -0
  18. package/src/appium-recorder/components/ComponentInspector.vue +26 -0
  19. package/src/appium-recorder/components/ComponentTree.vue +24 -4
  20. package/src/appium-recorder/components/FlowCanvas.vue +19 -1
  21. package/src/appium-recorder/components/FlowNodeCard.vue +6 -3
  22. package/src/appium-recorder/components/FlowNodeSearch.vue +62 -0
  23. package/src/appium-recorder/components/FlowStepEditor.vue +9 -4
  24. package/src/appium-recorder/components/LoopSettings.vue +37 -0
  25. package/src/appium-recorder/components/NestedConditionBranches.vue +11 -3
  26. package/src/appium-recorder/components/NodeDetail.vue +30 -6
  27. package/src/appium-recorder/components/RecordedSteps.vue +24 -4
  28. package/src/appium-recorder/components/RecorderWorkspace.vue +132 -0
  29. package/src/appium-recorder/components/VisualChangeEndPicker.vue +21 -0
  30. package/src/appium-recorder/flow-copy.ts +1 -0
  31. package/src/appium-recorder/flow-graph.ts +4 -9
  32. package/src/appium-recorder/flow-labels.ts +7 -0
  33. package/src/appium-recorder/flow-merge.ts +3 -2
  34. package/src/appium-recorder/flow-normalize.ts +1 -0
  35. package/src/appium-recorder/flow-search.ts +29 -0
  36. package/src/appium-recorder/node-locators.ts +25 -0
  37. package/src/appium-recorder/run-history.ts +50 -0
  38. package/src/appium-recorder/tree.ts +2 -5
  39. package/src/appium-recorder/types.ts +7 -0
  40. package/src/appium-recorder/visual-change-pairs.ts +16 -10
  41. package/src/components/device/DevicePreviewPanel.vue +62 -10
  42. package/src/style.css +24 -7
  43. package/src/ui-refresh.css +40 -0
@@ -1,4 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { captureHistoryFrames, readAppVersion } from './history-capture';
3
+ import { BoundedLoopTraversal } from './bounded-loop';
4
+ import { validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
2
5
  import { adbScreenshotBase64 } from './screenshot';
3
6
  import { recognizeDeviceScreen } from './ai-recognition';
4
7
  import { formatStageLog } from '../../src/appium-recorder/stage-log';
@@ -47,6 +50,8 @@ type PendingVisualChangeCheck = {
47
50
  step: AppiumRecordedStepRecord;
48
51
  nodeNumber: number;
49
52
  scriptName: string;
53
+ frameStart: number;
54
+ endFrameStart: number;
50
55
  };
51
56
 
52
57
  const replayContext = new AsyncLocalStorage<{
@@ -55,8 +60,10 @@ const replayContext = new AsyncLocalStorage<{
55
60
  serverUrl: string;
56
61
  deviceId: string;
57
62
  frames: AppiumReplayFrame[];
63
+ historyEvents: AppiumReplayFrame[];
58
64
  visualChecks: AppiumReplayVisualCheck[];
59
65
  pendingVisualChecks: PendingVisualChangeCheck[];
66
+ visualStartOffsets: Map<string, number>;
60
67
  softFailureCount: number;
61
68
  flowEnded?: boolean;
62
69
  }>();
@@ -330,6 +337,10 @@ async function captureReplayFrame(
330
337
  ) {
331
338
  const context = replayContext.getStore();
332
339
  if (!context || !context.deviceId) return;
340
+ // 执行记录独立于截图,设备截图失败不能让失败节点从统计中消失。
341
+ context.historyEvents.push({ sequence: context.historyEvents.length + 1, scriptName, nodeId: step.id,
342
+ nodeNumber, nodeLabel: step.label, nodeType: step.type, note: step.note || '',
343
+ selector: replayFrameSelector(step), phase, status, capturedAt: new Date().toISOString(), imageBase64: '' });
333
344
  const appendFrame = (imageBase64: string) => {
334
345
  context.frames.push({
335
346
  sequence: context.frames.length + 1,
@@ -517,6 +528,7 @@ async function saveScreenshot(deviceId: string) {
517
528
  type RunStepMeta = {
518
529
  nodeNumber: number;
519
530
  scriptName: string;
531
+ frameStart?: number;
520
532
  };
521
533
 
522
534
  type RunStepResult = string | {
@@ -558,10 +570,9 @@ function findVisualFrame(
558
570
  ) {
559
571
  const matches = frames.filter((frame) => frame.nodeId === nodeId && frame.scriptName === scriptName);
560
572
  if (!matches.length) return undefined;
561
- const preferred = preferredPhase === 'before'
562
- ? matches.find((frame) => frame.phase === preferredPhase)
563
- : [...matches].reverse().find((frame) => frame.phase === preferredPhase);
564
- return preferred || (preferredPhase === 'before' ? matches[0] : matches[matches.length - 1]);
573
+ const frame = preferredPhase === 'before' ? matches[0] : matches[matches.length - 1];
574
+ // A failed capture must not fall back to an older round or a before/error image.
575
+ return frame?.phase === preferredPhase ? frame : undefined;
565
576
  }
566
577
 
567
578
  const EMPTY_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
@@ -572,12 +583,14 @@ function registerVisualChangeStep(
572
583
  ): RunStepResult {
573
584
  const config = step.visualChange;
574
585
  if (!config) throw new Error(`${step.label} 缺少画面变化检测配置`);
586
+ const context = replayContext.getStore();
575
587
  if (config.role === 'start') {
588
+ // Invalidate the previous capture even when this start's after screenshot fails.
589
+ context?.visualStartOffsets.set(JSON.stringify([meta.frameStart || 0, step.id]), context.frames.length);
576
590
  return {
577
591
  message: `已记录画面变化基准点:${config.pairLabel || step.label}`,
578
592
  };
579
593
  }
580
- const context = replayContext.getStore();
581
594
  const endStep = {
582
595
  ...step,
583
596
  visualChange: {
@@ -585,7 +598,9 @@ function registerVisualChangeStep(
585
598
  endStepId: config.endStepId || step.id,
586
599
  },
587
600
  };
588
- context?.pendingVisualChecks.push({ step: endStep, nodeNumber: meta.nodeNumber, scriptName: meta.scriptName });
601
+ context?.pendingVisualChecks.push({ step: endStep, nodeNumber: meta.nodeNumber, scriptName: meta.scriptName,
602
+ frameStart: context.visualStartOffsets.get(JSON.stringify([meta.frameStart || 0, config.startStepId])) ?? meta.frameStart ?? 0,
603
+ endFrameStart: context.frames.length });
589
604
  return {
590
605
  message: `已记录画面变化对比点:${config.pairLabel || step.label}`,
591
606
  };
@@ -622,16 +637,16 @@ function processPendingVisualChecks(lines: string[]) {
622
637
  const context = replayContext.getStore();
623
638
  if (!context || !context.pendingVisualChecks.length) return;
624
639
 
625
- // Visual checks compare captured replay frames after the path finishes, so
626
- // they never block or mutate the device state for downstream nodes.
640
+ // Resolve each pair immediately after its end capture, before later rounds
641
+ // or linked-script calls can replace the images associated with these IDs.
627
642
  context.pendingVisualChecks.forEach((check) => {
628
643
  const config = check.step.visualChange;
629
644
  if (!config?.startStepId || !config.endStepId) {
630
645
  appendVisualCheckFailure(lines, check, `${check.step.label} 缺少开始节点或结束节点`);
631
646
  return;
632
647
  }
633
- const baselineFrame = findVisualFrame(context.frames, config.startStepId, check.scriptName, 'after');
634
- const comparisonFrame = findVisualFrame(context.frames, config.endStepId, check.scriptName, 'after');
648
+ const baselineFrame = findVisualFrame(context.frames.slice(check.frameStart, check.endFrameStart), config.startStepId, check.scriptName, 'after');
649
+ const comparisonFrame = findVisualFrame(context.frames.slice(check.endFrameStart), config.endStepId, check.scriptName, 'after');
635
650
  if (!baselineFrame || !comparisonFrame) {
636
651
  const missing = [
637
652
  baselineFrame ? '' : '开始节点截图',
@@ -916,6 +931,15 @@ async function readNativeControlState(sessionId: string, step: AppiumRecordedSte
916
931
  }
917
932
 
918
933
  async function evaluateCondition(sessionId: string, deviceId: string, step: AppiumRecordedStepRecord) {
934
+ if (step.type === 'loop') {
935
+ if (step.loop?.exitWhen === 'never') return false;
936
+ const selector = toAppiumUsing(step.selector!);
937
+ const payload = await appiumRequest<AppiumValueResponse<Record<string, string>[]>>(`/session/${sessionId}/elements`, {
938
+ method: 'POST', body: JSON.stringify(selector),
939
+ });
940
+ if (!Array.isArray(payload.value)) throw new Error('循环退出条件查询返回了无效结果');
941
+ return step.loop?.exitWhen === 'exists' ? payload.value.length > 0 : payload.value.length === 0;
942
+ }
919
943
  if (step.type === 'textClick') {
920
944
  const selector = textClickSelector(step);
921
945
  const deadline = Date.now() + Math.max(0, step.timeoutMs ?? 10000);
@@ -994,12 +1018,17 @@ async function appendStepDiagnostics(lines: string[], deviceId: string, step: Ap
994
1018
  }
995
1019
 
996
1020
  type ReplayStepOptions = {
1021
+ frameStart?: number;
997
1022
  skipAppInitialization?: boolean;
998
1023
  scriptName?: string;
999
1024
  };
1000
1025
 
1001
- function shouldSkipLinkedScriptInitStep(options: ReplayStepOptions, step: AppiumRecordedStepRecord) {
1002
- return Boolean(options.skipAppInitialization && (step.type === 'launchApp' || step.type === 'clearAppData'));
1026
+ function shouldSkipLinkedScriptInitStep(options: ReplayStepOptions, step: AppiumRecordedStepRecord, steps: AppiumRecordedStepRecord[]) {
1027
+ if (!options.skipAppInitialization) return false;
1028
+ // Only the leading initialization sequence is redundant in a linked script.
1029
+ // Launches inside branches or after other operations are intentional actions.
1030
+ return steps.slice(0, steps.indexOf(step) + 1).every(item =>
1031
+ !item.flow?.parentConditionId && (item.type === 'launchApp' || item.type === 'clearAppData'));
1003
1032
  }
1004
1033
 
1005
1034
  function linkedScriptInitSkipReason(step: AppiumRecordedStepRecord) {
@@ -1079,7 +1108,7 @@ async function replayLinearSteps(
1079
1108
  for (const [index, step] of steps.entries()) {
1080
1109
  if (replayContext.getStore()?.flowEnded) return;
1081
1110
  throwIfReplayStopped();
1082
- if (shouldSkipLinkedScriptInitStep(options, step)) {
1111
+ if (shouldSkipLinkedScriptInitStep(options, step, steps)) {
1083
1112
  lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
1084
1113
  continue;
1085
1114
  }
@@ -1093,11 +1122,13 @@ async function replayLinearSteps(
1093
1122
  const result = await runStep(sessionId, deviceId, step, {
1094
1123
  nodeNumber: index + 1,
1095
1124
  scriptName: options.scriptName || '',
1125
+ frameStart: options.frameStart,
1096
1126
  });
1097
1127
  softFailed = appendRunStepResult(lines, index + 1, result);
1098
1128
  }
1099
1129
  lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
1100
1130
  await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
1131
+ processPendingVisualChecks(lines);
1101
1132
  } catch (error) {
1102
1133
  if (isReplayStopped(error)) {
1103
1134
  lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
@@ -1121,47 +1152,47 @@ async function replayFlowSteps(
1121
1152
  options: ReplayStepOptions = {},
1122
1153
  ) {
1123
1154
  const idToIndex = new Map(steps.map((step, index) => [step.id, index]));
1124
- const conditionContinuationIndex = (conditionId: string) => {
1125
- const conditionIndex = idToIndex.get(conditionId);
1126
- if (conditionIndex === undefined) return undefined;
1127
- const explicitTargetId = steps[conditionIndex]?.flow?.successTargetId;
1128
- return explicitTargetId ? idToIndex.get(explicitTargetId) : undefined;
1129
- };
1130
- const nextIndexAfterStep = (currentIndex: number, step: AppiumRecordedStepRecord) => {
1131
- const explicitTargetId = step.flow?.successTargetId;
1132
- if (explicitTargetId) return idToIndex.get(explicitTargetId);
1133
- if (!step.flow?.parentConditionId || !step.flow.parentBranch) return currentIndex + 1;
1134
- const nextStep = steps[currentIndex + 1];
1135
- const staysInBranch = nextStep?.flow?.parentConditionId === step.flow.parentConditionId
1136
- && nextStep.flow.parentBranch === step.flow.parentBranch;
1137
- return staysInBranch
1138
- ? currentIndex + 1
1139
- : conditionContinuationIndex(step.flow.parentConditionId);
1140
- };
1155
+ const traversal = new BoundedLoopTraversal(steps);
1156
+ const nextIndexAfterStep = (index: number, step: AppiumRecordedStepRecord) => traversal.next(step, index);
1141
1157
  const visitedPath: string[] = [];
1142
1158
  let index: number | undefined = 0;
1143
- let guard = 0;
1144
- const maxVisits = Math.max(steps.length * 4, 20);
1145
1159
 
1146
1160
  while (typeof index === 'number' && index >= 0 && index < steps.length) {
1147
1161
  if (replayContext.getStore()?.flowEnded) return;
1148
1162
  throwIfReplayStopped();
1149
- guard += 1;
1150
- if (guard > maxVisits) throw new Error('流程图可能存在循环,已终止回放');
1151
-
1152
1163
  const step = steps[index];
1153
- if (shouldSkipLinkedScriptInitStep(options, step)) {
1164
+ traversal.visit(step);
1165
+ const loopContext = traversal.context(step);
1166
+ const frameStep = loopContext ? { ...step, label: `${step.label}(${loopContext})` } : step;
1167
+ if (shouldSkipLinkedScriptInitStep(options, step, steps)) {
1154
1168
  lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
1155
1169
  index = nextIndexAfterStep(index, step);
1156
1170
  continue;
1157
1171
  }
1158
1172
  visitedPath.push(step.label);
1159
1173
  const nodeKind = defaultFlowKind(step);
1160
- lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
1161
- await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'before', '执行前');
1174
+ lines.push(`[节点 ${index + 1}] 开始:${frameStep.label}`);
1175
+ await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'before', '执行前');
1162
1176
 
1163
1177
  if (nodeKind === 'condition') {
1164
1178
  try {
1179
+ if (step.type === 'loop') {
1180
+ const iterations = traversal.iteration(step);
1181
+ const atLimit = iterations >= step.loop!.maxIterations;
1182
+ const exitMatched = !atLimit && await evaluateCondition(sessionId, deviceId, step);
1183
+ if (atLimit || exitMatched) {
1184
+ const reason = atLimit ? '达到最大次数' : '满足退出条件';
1185
+ lines.push(`[循环 ${step.label}] 结束:${reason},已执行 ${iterations} 轮`);
1186
+ await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', `${reason},已执行 ${iterations} 轮`);
1187
+ index = traversal.exit(step);
1188
+ } else {
1189
+ const entered = traversal.enter(step);
1190
+ lines.push(`[循环 ${step.label}] 第 ${entered.iteration}/${step.loop!.maxIterations} 轮开始`);
1191
+ await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', `第 ${entered.iteration}/${step.loop!.maxIterations} 轮`);
1192
+ index = entered.next;
1193
+ }
1194
+ continue;
1195
+ }
1165
1196
  // AI 节点使用本次执行前截图,并将同一张图片和识别依据写入结果帧。
1166
1197
  const beforeFrame = replayContext.getStore()?.frames.at(-1);
1167
1198
  const recognition = step.type === 'aiRecognition' ? await recognizeDeviceScreen({
@@ -1172,19 +1203,20 @@ async function replayFlowSteps(
1172
1203
  const matched = recognition ? recognition.result : await evaluateCondition(sessionId, deviceId, step);
1173
1204
  if (recognition) lines.push(`[节点 ${index + 1}] AI 识别:${recognition.result},耗时 ${recognition.durationMs}ms${recognition.reason ? `,依据:${recognition.reason}` : ''}`);
1174
1205
  const branchTargetId = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
1175
- const targetId = branchTargetId || step.flow?.successTargetId || '';
1206
+ const next = traversal.branch(step, matched);
1207
+ const targetId = branchTargetId || (next === undefined ? '' : steps[next]?.id) || '';
1176
1208
  const branchLabel = flowBranchLabel(step, matched ? 'yes' : 'no');
1177
1209
  lines.push(`[节点 ${index + 1}] 判断:${branchLabel}${isNativeStateCondition(step) ? `(checked=${matched})` : ''}${targetId ? `,进入 ${targetId}` : ',流程结束'}`);
1178
1210
  await captureReplayFrame(
1179
1211
  sessionId,
1180
- step,
1212
+ frameStep,
1181
1213
  index + 1,
1182
1214
  options.scriptName || '',
1183
1215
  'after',
1184
1216
  `判断:${branchLabel}${recognition?.reason ? `;${recognition.reason}` : ''}`,
1185
1217
  recognition?.imageBase64,
1186
1218
  );
1187
- index = targetId ? idToIndex.get(targetId) : undefined;
1219
+ index = next;
1188
1220
  } catch (error) {
1189
1221
  if (isReplayStopped(error)) {
1190
1222
  lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
@@ -1201,17 +1233,25 @@ async function replayFlowSteps(
1201
1233
 
1202
1234
  try {
1203
1235
  let softFailed = false;
1204
- if (step.type === 'runScript') {
1236
+ if (step.type === 'breakLoop') {
1237
+ const exited = traversal.break(step);
1238
+ lines.push(`[循环 ${exited.loop.label}] 第 ${exited.iteration} 轮主动退出`);
1239
+ await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', `已退出循环:${exited.loop.label}`);
1240
+ index = exited.next;
1241
+ continue;
1242
+ } else if (step.type === 'runScript') {
1205
1243
  await replayLinkedScript(sessionId, deviceId, step, lines, stack);
1206
1244
  } else {
1207
1245
  const result = await runStep(sessionId, deviceId, step, {
1208
1246
  nodeNumber: index + 1,
1209
1247
  scriptName: options.scriptName || '',
1248
+ frameStart: options.frameStart,
1210
1249
  });
1211
1250
  softFailed = appendRunStepResult(lines, index + 1, result);
1212
1251
  }
1213
1252
  lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
1214
- await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
1253
+ await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
1254
+ processPendingVisualChecks(lines);
1215
1255
  index = nextIndexAfterStep(index, step);
1216
1256
  } catch (error) {
1217
1257
  if (isReplayStopped(error)) {
@@ -1259,6 +1299,8 @@ async function replayScriptSteps(
1259
1299
  stack: string[],
1260
1300
  options: ReplayStepOptions = {},
1261
1301
  ) {
1302
+ validateLoopSteps(steps);
1303
+ options = { ...options, frameStart: replayContext.getStore()?.frames.length || 0 };
1262
1304
  let trailingLinkIndex = steps.length;
1263
1305
  while (
1264
1306
  trailingLinkIndex > 0
@@ -1306,6 +1348,7 @@ export async function replayAppiumScript(
1306
1348
  if (!script.steps.length) throw new Error('脚本没有可回放步骤');
1307
1349
 
1308
1350
  const startedAt = new Date();
1351
+ const appVersion = await readAppVersion(targetDeviceId, script.appPackage).catch(() => '');
1309
1352
  const lines: string[] = [];
1310
1353
  const pushLine = lines.push.bind(lines);
1311
1354
  lines.push = (...items: string[]) => {
@@ -1319,7 +1362,9 @@ export async function replayAppiumScript(
1319
1362
  serverUrl: configuredAppiumServerUrl(),
1320
1363
  deviceId: targetDeviceId,
1321
1364
  frames: [] as AppiumReplayFrame[],
1365
+ historyEvents: [] as AppiumReplayFrame[],
1322
1366
  visualChecks: [] as AppiumReplayVisualCheck[],
1367
+ visualStartOffsets: new Map<string, number>(),
1323
1368
  pendingVisualChecks: [] as PendingVisualChangeCheck[],
1324
1369
  softFailureCount: 0,
1325
1370
  };
@@ -1334,6 +1379,7 @@ export async function replayAppiumScript(
1334
1379
  let stopped = false;
1335
1380
  let managedAppium: Awaited<ReturnType<typeof startManagedAppiumServer>> | null = null;
1336
1381
  try {
1382
+ validateLoopSteps(script.steps);
1337
1383
  lines.push('正在检测 Android SDK...');
1338
1384
  const androidSdk = ensureAndroidSdkAvailable();
1339
1385
  lines.push(`Android SDK 已就绪:${androidSdk.root}`);
@@ -1441,6 +1487,14 @@ export async function replayAppiumScript(
1441
1487
  logPath,
1442
1488
  htmlReportPath,
1443
1489
  softFailureCount: context.softFailureCount,
1490
+ history: {
1491
+ scriptId: script.id, scriptName: script.name, appPackage: script.appPackage,
1492
+ appVersion, deviceId: targetDeviceId, startedAt: startedAt.toISOString(),
1493
+ durationMs: completedAt.getTime() - startedAt.getTime(),
1494
+ status: stopped ? 'stopped' as const : success ? 'passed' as const : 'failed' as const,
1495
+ output: lines.join('\n'),
1496
+ ...captureHistoryFrames(context.frames, context.visualChecks, context.historyEvents),
1497
+ },
1444
1498
  };
1445
1499
  });
1446
1500
  }
@@ -0,0 +1,118 @@
1
+ import type { AppiumRecordedStep } from '../../src/appium-recorder/types';
2
+ import { enclosingLoop, enclosingLoops, breakLoopTarget, validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
3
+ import { defaultFlowKind } from '../../src/appium-recorder/flow-labels';
4
+
5
+ /** Virtual returns keep the saved flow graph acyclic and preserve branch layout. */
6
+ export class BoundedLoopTraversal {
7
+ private readonly byId: Map<string, number>;
8
+ private readonly active = new Map<string, { iteration: number; visited: Set<string> }>();
9
+ private readonly rootVisited = new Set<string>();
10
+ private readonly owners = new Map<string, AppiumRecordedStep | undefined>();
11
+
12
+ constructor(private readonly steps: AppiumRecordedStep[]) {
13
+ validateLoopSteps(steps);
14
+ this.byId = new Map(steps.map((step, index) => [step.id, index]));
15
+ }
16
+
17
+ private target(id?: string) { return id ? this.byId.get(id) : undefined; }
18
+ private owner(step: AppiumRecordedStep) {
19
+ if (!this.owners.has(step.id)) this.owners.set(step.id, enclosingLoop(this.steps, step));
20
+ return this.owners.get(step.id);
21
+ }
22
+
23
+ visit(step: AppiumRecordedStep) {
24
+ if (step.type === 'loop' && this.active.has(step.id)) return;
25
+ const owner = this.owner(step);
26
+ const frame = owner ? this.active.get(owner.id) : undefined;
27
+ if (owner && !frame) throw new Error('不能从循环外直接进入循环体');
28
+ const visited = frame?.visited || this.rootVisited;
29
+ if (visited.has(step.id)) throw new Error('流程存在非有界循环回边,已终止回放');
30
+ visited.add(step.id);
31
+ }
32
+
33
+ private continuation(step: AppiumRecordedStep, seen = new Set<string>()): number | undefined {
34
+ if (seen.has(step.id)) throw new Error('流程分支归属存在循环');
35
+ seen.add(step.id);
36
+ if (step.flow?.successTargetId) return this.target(step.flow.successTargetId);
37
+ if (defaultFlowKind(step) === 'condition') {
38
+ const start = this.byId.get(step.id)!;
39
+ const sibling = this.steps.findIndex((item, index) => index > start && item.flow?.parentConditionId === step.flow?.parentConditionId && item.flow?.parentBranch === step.flow?.parentBranch);
40
+ if (sibling >= 0) return sibling;
41
+ }
42
+ const parentIndex = this.target(step.flow?.parentConditionId);
43
+ if (parentIndex === undefined) return;
44
+ const parent = this.steps[parentIndex]!;
45
+ if (parent.type === 'loop' && step.flow?.parentBranch === 'yes') return parentIndex;
46
+ return this.continuation(parent, seen);
47
+ }
48
+
49
+ private returnAtBoundary(step: AppiumRecordedStep, next: number | undefined) {
50
+ const owner = this.owner(step);
51
+ if (!owner || !this.active.has(owner.id)) return next;
52
+ // The end of any nested branch returns to the loop, not to the other branch.
53
+ if (next === undefined || (this.steps[next]?.id !== owner.id && !this.isInBody(this.steps[next]!, owner.id))) {
54
+ return this.target(owner.id);
55
+ }
56
+ return next;
57
+ }
58
+
59
+ private isInBody(step: AppiumRecordedStep, loopId: string) {
60
+ let owner = this.owner(step);
61
+ const seen = new Set<string>();
62
+ while (owner && !seen.has(owner.id)) {
63
+ if (owner.id === loopId) return true;
64
+ seen.add(owner.id); owner = this.owner(owner);
65
+ }
66
+ return false;
67
+ }
68
+
69
+ next(step: AppiumRecordedStep, index: number) {
70
+ let next = this.target(step.flow?.successTargetId);
71
+ if (!step.flow?.successTargetId) {
72
+ const sibling = this.steps[index + 1];
73
+ next = sibling && sibling.flow?.parentConditionId === step.flow?.parentConditionId && sibling.flow?.parentBranch === step.flow?.parentBranch
74
+ ? index + 1 : this.continuation(step);
75
+ }
76
+ return this.returnAtBoundary(step, next);
77
+ }
78
+
79
+ branch(step: AppiumRecordedStep, matched: boolean) {
80
+ const id = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
81
+ return this.returnAtBoundary(step, id ? this.target(id) : this.continuation(step));
82
+ }
83
+
84
+ iteration(step: AppiumRecordedStep) { return this.active.get(step.id)?.iteration || 0; }
85
+
86
+ enter(step: AppiumRecordedStep) {
87
+ const iteration = this.iteration(step) + 1;
88
+ if (iteration > step.loop!.maxIterations) throw new Error('循环次数超过上限');
89
+ this.active.set(step.id, { iteration, visited: new Set() });
90
+ return { iteration, next: this.target(step.flow?.yesTargetId) ?? this.target(step.id) };
91
+ }
92
+
93
+ exit(step: AppiumRecordedStep) {
94
+ this.active.delete(step.id);
95
+ return this.returnAtBoundary(step, this.target(step.flow?.noTargetId) ?? this.continuation(step));
96
+ }
97
+
98
+ break(step: AppiumRecordedStep) {
99
+ const owner = breakLoopTarget(this.steps, step);
100
+ if (!this.active.has(owner.id)) throw new Error('目标循环当前未执行');
101
+ // Unwind inner frames without running their completion branches.
102
+ for (const inner of enclosingLoops(this.steps, step)) {
103
+ if (inner.id === owner.id) break;
104
+ this.active.delete(inner.id);
105
+ }
106
+ return { loop: owner, iteration: this.iteration(owner), next: this.exit(owner) };
107
+ }
108
+
109
+ context(step: AppiumRecordedStep) {
110
+ const parts: string[] = [];
111
+ let owner = this.owner(step);
112
+ while (owner) {
113
+ parts.unshift(`${owner.label} 第 ${this.iteration(owner)}/${owner.loop!.maxIterations} 轮`);
114
+ owner = this.owner(owner);
115
+ }
116
+ return parts.join(' / ');
117
+ }
118
+ }
@@ -0,0 +1,34 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { getAdbCommand } from '../android-sdk';
3
+ import { htmlImageUrl, type AppiumReplayFrame, type AppiumReplayVisualCheck } from './report';
4
+ import type { HistoryNode } from '../../src/appium-recorder/run-history';
5
+
6
+ export function readAppVersion(deviceId: string, appPackage: string): Promise<string> {
7
+ if (!appPackage) return Promise.resolve('');
8
+ return new Promise(resolve => {
9
+ execFile(getAdbCommand(), ['-s', deviceId, 'shell', 'dumpsys', 'package', appPackage], { timeout: 5000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
10
+ if (error) return resolve('');
11
+ const name = stdout.match(/\bversionName=([^\r\n]+)/)?.[1]?.trim();
12
+ const code = stdout.match(/\bversionCode=(\d+)/)?.[1];
13
+ resolve(name ? `${name}${code ? ` (${code})` : ''}` : '');
14
+ });
15
+ });
16
+ }
17
+
18
+ export function captureHistoryFrames(frames: AppiumReplayFrame[], checks: AppiumReplayVisualCheck[], events = frames) {
19
+ const cache = new Map<string, string>();
20
+ const nodes: HistoryNode[] = [];
21
+ const pending = new Map<string, AppiumReplayFrame>();
22
+ for (const frame of events) {
23
+ const key = frame.nodeId;
24
+ if (frame.phase === 'before') { pending.set(key, frame); continue; }
25
+ const start = pending.get(key);
26
+ pending.delete(key);
27
+ if (frame.phase !== 'stopped') nodes.push({ key, label: `${frame.scriptName} / ${frame.nodeLabel}`, failed: frame.phase === 'error', durationMs: start ? Math.max(0, Date.parse(frame.capturedAt) - Date.parse(start.capturedAt)) : null });
28
+ }
29
+ for (const check of checks.filter(check => check.status === 'failed')) {
30
+ const node = nodes.find(node => node.key === check.nodeId);
31
+ if (node) node.failed = true;
32
+ }
33
+ return { nodes, frames: frames.map(frame => ({ key: frame.nodeId, label: frame.nodeLabel, phase: frame.phase, capturedAt: frame.capturedAt, imageUrl: htmlImageUrl(frame.imageBase64, cache) })) };
34
+ }
@@ -126,7 +126,7 @@ function compressHtmlPngBase64(base64: string) {
126
126
  }
127
127
  }
128
128
 
129
- function htmlImageUrl(base64: string, cache: Map<string, string>) {
129
+ export function htmlImageUrl(base64: string, cache: Map<string, string>) {
130
130
  const cached = cache.get(base64);
131
131
  if (cached) return cached;
132
132
 
@@ -7,6 +7,7 @@ import {
7
7
  sqlString,
8
8
  } from '../storage/sqlite';
9
9
  import { normalizeLegacyNestedConditionBranches } from '../../src/appium-recorder/flow-normalize';
10
+ import { validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
10
11
 
11
12
  export type AppiumRecordedStepRecord = {
12
13
  id: string;
@@ -34,6 +35,8 @@ export type AppiumRecordedStepRecord = {
34
35
  | 'launchApp'
35
36
  | 'openGallery'
36
37
  | 'endFlow'
38
+ | 'loop'
39
+ | 'breakLoop'
37
40
  | 'clearAppData'
38
41
  | 'waitDisappear'
39
42
  | 'assertText'
@@ -86,6 +89,11 @@ export type AppiumRecordedStepRecord = {
86
89
  keyCode?: number;
87
90
  timeoutMs?: number;
88
91
  longPressMode?: 'element' | 'coordinates';
92
+ breakLoopTargetId?: string;
93
+ loop?: {
94
+ maxIterations: number;
95
+ exitWhen: 'never' | 'exists' | 'notExists';
96
+ };
89
97
  flow?: {
90
98
  nodeKind?: 'action' | 'condition' | 'assertion';
91
99
  yesTargetId?: string;
@@ -338,6 +346,7 @@ export function saveAppiumRecordedScript(input: {
338
346
  const name = input.name.trim();
339
347
  const appPackage = input.appPackage.trim();
340
348
  const steps = normalizeLegacyNestedConditionBranches(input.steps || []);
349
+ validateLoopSteps(steps);
341
350
  const appActivity = normalizeScriptActivity(appPackage, input.appActivity, steps);
342
351
 
343
352
  if (!name) throw new Error('脚本名称不能为空');
@@ -1,4 +1,5 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { deleteRunHistory, getRunHistory, listRunHistory, saveRunHistory } from './run-history';
2
3
  import { recognizeDeviceScreen } from './ai-recognition';
3
4
  import { readFreshWindowHierarchy } from './tree-dump';
4
5
  import type { IncomingMessage, ServerResponse } from 'node:http';
@@ -307,6 +308,21 @@ export async function handleAppiumRecorderRequest(
307
308
  return true;
308
309
  }
309
310
 
311
+ const historyMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/history(?:\/([^/]+))?$/);
312
+ if (historyMatch) {
313
+ const scriptId = decodeURIComponent(historyMatch[1]);
314
+ const id = historyMatch[2] ? decodeURIComponent(historyMatch[2]) : '';
315
+ if (req.method === 'GET') {
316
+ const data = id ? getRunHistory(scriptId, id) : listRunHistory(scriptId);
317
+ sendJson(res, data, data ? 200 : 404);
318
+ return true;
319
+ }
320
+ if (req.method === 'DELETE' && id) {
321
+ deleteRunHistory(scriptId, id);
322
+ sendJson(res, { success: true });
323
+ return true;
324
+ }
325
+ }
310
326
  const replayMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/replay$/);
311
327
  if (replayMatch && req.method === 'POST') {
312
328
  const parsed = await readBody<{ deviceId?: string }>(req);
@@ -323,13 +339,18 @@ export async function handleAppiumRecorderRequest(
323
339
  res.flushHeaders();
324
340
  }
325
341
  if (isRemoteDeviceId(deviceId)) {
326
- const result = await sendRemoteCommand(deviceId, 'replay', { script }) as { success?: boolean; output?: string };
342
+ const result = await sendRemoteCommand(deviceId, 'replay', { script }) as Awaited<ReturnType<typeof replayAppiumScript>>;
343
+ if (result.history) {
344
+ try { saveRunHistory({ ...result.history, scriptId: script.id, deviceId }); }
345
+ catch { result.output += '\n历史记录保存失败'; }
346
+ }
347
+ const { history: _history, ...response } = result;
327
348
  if (streamOutput) {
328
349
  result.output?.split(/\r?\n/).forEach((line) => sendStreamEvent(res, { type: 'log', line }));
329
- sendStreamEvent(res, { type: 'result', ...result });
350
+ sendStreamEvent(res, { type: 'result', ...response });
330
351
  res.end();
331
352
  } else {
332
- sendJson(res, result, result.success ? 200 : 500);
353
+ sendJson(res, response, result.success ? 200 : 500);
333
354
  }
334
355
  return true;
335
356
  }
@@ -346,11 +367,14 @@ export async function handleAppiumRecorderRequest(
346
367
  streamOutput ? (line) => sendStreamEvent(res, { type: 'log', line }) : undefined,
347
368
  replayAbortController.signal,
348
369
  );
370
+ try { saveRunHistory(result.history); }
371
+ catch { result.output += '\n历史记录保存失败'; }
372
+ const { history: _history, ...response } = result;
349
373
  if (streamOutput) {
350
- sendStreamEvent(res, { type: 'result', ...result });
374
+ sendStreamEvent(res, { type: 'result', ...response });
351
375
  res.end();
352
376
  } else {
353
- sendJson(res, result, result.success ? 200 : 500);
377
+ sendJson(res, response, result.success ? 200 : 500);
354
378
  }
355
379
  } finally {
356
380
  replayingDevices.delete(deviceId);
@@ -0,0 +1,34 @@
1
+ import { createId, querySql, runSql, sqlJson, sqlString } from '../storage/sqlite';
2
+ import type { RunDetail, RunSummary } from '../../src/appium-recorder/run-history';
3
+
4
+ function ensureTable() {
5
+ runSql(`CREATE TABLE IF NOT EXISTS appium_run_history (
6
+ id TEXT PRIMARY KEY, script_id TEXT NOT NULL, started_at TEXT NOT NULL,
7
+ summary TEXT NOT NULL, detail TEXT NOT NULL
8
+ ); CREATE INDEX IF NOT EXISTS appium_run_history_script ON appium_run_history(script_id, started_at);`);
9
+ }
10
+
11
+ export function saveRunHistory(input: Omit<RunDetail, 'id'>) {
12
+ ensureTable();
13
+ const record = { ...input, id: createId('run') };
14
+ const { frames, output, ...summary } = record;
15
+ runSql(`INSERT INTO appium_run_history VALUES (${sqlString(record.id)}, ${sqlString(record.scriptId)}, ${sqlString(record.startedAt)}, ${sqlJson(summary)}, ${sqlJson({ frames, output })});`);
16
+ return record.id;
17
+ }
18
+
19
+ export function listRunHistory(scriptId: string): RunSummary[] {
20
+ ensureTable();
21
+ return (querySql<{ summary: string }>(`SELECT summary FROM appium_run_history WHERE script_id=${sqlString(scriptId)} ORDER BY started_at DESC;`) || []).map(row => JSON.parse(row.summary));
22
+ }
23
+
24
+ export function getRunHistory(scriptId: string, id: string): RunDetail | null {
25
+ ensureTable();
26
+ const row = querySql<{ summary: string; detail: string }>(`SELECT summary, detail FROM appium_run_history WHERE script_id=${sqlString(scriptId)} AND id=${sqlString(id)};`)?.[0];
27
+ return row ? { ...JSON.parse(row.summary), ...JSON.parse(row.detail) } : null;
28
+ }
29
+
30
+ export function deleteRunHistory(scriptId: string, id: string) {
31
+ ensureTable();
32
+ // 不接受文件路径,也不删除原始报告;只删除这条历史的自包含快照。
33
+ runSql(`PRAGMA secure_delete=ON; DELETE FROM appium_run_history WHERE script_id=${sqlString(scriptId)} AND id=${sqlString(id)};`);
34
+ }