android-midscene-automation 0.1.31 → 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.
package/CHANGELOG.md CHANGED
@@ -1,10 +1,19 @@
1
1
  # 更新记录
2
2
 
3
+ ## v0.1.32
4
+ - 添加画面变化结束节点时,多组可配对检测支持按名称及开始节点编号选择,允许交叉结束;只有一组时直接添加,保留分支及重复结束校验。
5
+ - 画面变化结束节点校验覆盖祖先及子分支:同一执行路径已存在对应结束节点时,后续分支不能重复添加;互斥分支仍可分别结束。
6
+ - 脚本列表新增“历史结果对比”:保存运行结果、应用版本、设备、截图和日志,支持筛选查看通过率、耗时趋势与失败节点分布,对比两次运行并删除选中历史;手动终止不计入通过率,旧报告不自动补录。
7
+ - 画面变化检测在每个结束节点执行后立即对比本次截图,避免循环及重复调用脚本时多组检测复用最后一轮图片;截图缺失时明确报告异常,不使用旧图替代。
8
+ - “启动 APP”支持在流程中途、判断分支及循环体内多次添加,按所选位置执行;连接脚本仅跳过开头的应用初始化,中途启动操作正常保留。
9
+ - Midscene 相关模块统一从 1.12.2 升级至 1.12.5。
10
+
11
+
3
12
  ## v0.1.31
4
13
  - 组件详情展示 Resource ID、Accessibility ID、XPath 和 UiAutomator 四种定位内容,支持分别复制,方便配置循环退出条件;缺少对应属性时显示未提供。
5
14
  - “流程控制”新增有界循环及退出循环:支持固定次数、元素出现或消失时退出,必须设置最大执行次数;循环体支持嵌套判断、合流和连接脚本。退出节点可选择当前或外层循环,退出外层时同时结束内部循环并直接执行目标循环结束路径,日志及 HTML 报告标注轮次和退出目标。
6
15
  - 新增流程节点搜索,支持按名称、操作类型、定位内容和日志关键字筛选,展示所属分支并在多个结果间切换;普通画布与流程总览支持定位高亮,不改变节点布局及批量选中状态。
7
- - Midscene 相关模块统一从 1.12.0 升级至 1.12.2,包含 Android、Android Playground、Core、Playground、Shared 和 Web
16
+ - Midscene 相关模块统一从 1.12.0 升级至 1.12.2。
8
17
  - 录制工作区支持拖拽调整设备预览、组件树栏宽,自动保存布局并支持恢复默认。
9
18
 
10
19
  ## v0.1.30
package/README.md CHANGED
@@ -1229,11 +1229,20 @@ Linux: ~/.local/share/android-midscene-automation
1229
1229
 
1230
1230
  # 项目更新记录
1231
1231
 
1232
+ ## v0.1.32
1233
+ - 添加画面变化结束节点时,多组可配对检测支持按名称及开始节点编号选择,允许交叉结束;只有一组时直接添加,保留分支及重复结束校验。
1234
+ - 画面变化结束节点校验覆盖祖先及子分支:同一执行路径已存在对应结束节点时,后续分支不能重复添加;互斥分支仍可分别结束。
1235
+ - 脚本列表新增“历史结果对比”:保存运行结果、应用版本、设备、截图和日志,支持筛选查看通过率、耗时趋势与失败节点分布,对比两次运行并删除选中历史;手动终止不计入通过率,旧报告不自动补录。
1236
+ - 画面变化检测在每个结束节点执行后立即对比本次截图,避免循环及重复调用脚本时多组检测复用最后一轮图片;截图缺失时明确报告异常,不使用旧图替代。
1237
+ - “启动 APP”支持在流程中途、判断分支及循环体内多次添加,按所选位置执行;连接脚本仅跳过开头的应用初始化,中途启动操作正常保留。
1238
+ - Midscene 相关模块统一从 1.12.2 升级至 1.12.5。
1239
+
1240
+
1232
1241
  ## v0.1.31
1233
1242
  - 组件详情展示 Resource ID、Accessibility ID、XPath 和 UiAutomator 四种定位内容,支持分别复制,方便配置循环退出条件;缺少对应属性时显示未提供。
1234
1243
  - “流程控制”新增有界循环及退出循环:支持固定次数、元素出现或消失时退出,必须设置最大执行次数;循环体支持嵌套判断、合流和连接脚本。退出节点可选择当前或外层循环,退出外层时同时结束内部循环并直接执行目标循环结束路径,日志及 HTML 报告标注轮次和退出目标。
1235
1244
  - 新增流程节点搜索,支持按名称、操作类型、定位内容和日志关键字筛选,展示所属分支并在多个结果间切换;普通画布与流程总览支持定位高亮,不改变节点布局及批量选中状态。
1236
- - Midscene 相关模块统一从 1.12.0 升级至 1.12.2,包含 Android、Android Playground、Core、Playground、Shared 和 Web
1245
+ - Midscene 相关模块统一从 1.12.0 升级至 1.12.2。
1237
1246
  - 录制工作区支持拖拽调整设备预览、组件树栏宽,自动保存布局并支持恢复默认。
1238
1247
 
1239
1248
  ## v0.1.30
@@ -0,0 +1,49 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+
3
+ const generatedStart = '<!-- generated-docs:start -->';
4
+ const generatedEnd = '<!-- generated-docs:end -->';
5
+ const readmeUrl = new URL('../README.md', import.meta.url);
6
+
7
+ const stripFirstHeading = (markdown) =>
8
+ markdown.replace(/^#\s+[^\r\n]+\r?\n+/, '').trim();
9
+
10
+ const stripUsageFaqPointer = (markdown) =>
11
+ markdown.replace(
12
+ /\n## 常见问题\r?\n\r?\n常见问题已独立整理到 \[FAQ\.md\]\(\.\/FAQ\.md\)。\r?\n/g,
13
+ '\n',
14
+ );
15
+
16
+ const linkReadmeAnchors = (markdown) =>
17
+ markdown.replace(/\[FAQ\.md\]\(\.\/FAQ\.md\)/g, '[常见问题](#常见问题)');
18
+
19
+ const [readme, usage, faq, changelog] = await Promise.all([
20
+ readFile(readmeUrl, 'utf8'),
21
+ readFile(new URL('../USAGE.md', import.meta.url), 'utf8'),
22
+ readFile(new URL('../FAQ.md', import.meta.url), 'utf8'),
23
+ readFile(new URL('../CHANGELOG.md', import.meta.url), 'utf8'),
24
+ ]);
25
+
26
+ const generatedIndex = readme.indexOf(generatedStart);
27
+ const readmeBody = (
28
+ generatedIndex >= 0 ? readme.slice(0, generatedIndex) : readme
29
+ ).trimEnd();
30
+
31
+ const generatedDocs = [
32
+ generatedStart,
33
+ '',
34
+ '# Appium 录制器使用说明',
35
+ '',
36
+ linkReadmeAnchors(stripUsageFaqPointer(stripFirstHeading(usage))),
37
+ '',
38
+ '# 常见问题',
39
+ '',
40
+ stripFirstHeading(faq),
41
+ '',
42
+ '# 项目更新记录',
43
+ '',
44
+ stripFirstHeading(changelog),
45
+ '',
46
+ generatedEnd,
47
+ ].join('\n');
48
+
49
+ await writeFile(readmeUrl, `${readmeBody}\n\n${generatedDocs}\n`, 'utf8');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
@@ -28,7 +28,7 @@
28
28
  ],
29
29
  "scripts": {
30
30
  "dev": "vite --host 127.0.0.1",
31
- "prepack": "node scripts/sync-readme-docs.mjs",
31
+ "prepack": "node bin/sync-readme-docs.mjs",
32
32
  "remote-agent": "tsx remote-agent/index.ts",
33
33
  "build": "vue-tsc -b && vite build",
34
34
  "preview": "vite preview --host 127.0.0.1",
@@ -38,12 +38,12 @@
38
38
  "dependencies": {
39
39
  "@dagrejs/dagre": "1.1.5",
40
40
  "@element-plus/icons-vue": "2.3.2",
41
- "@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.2.tgz",
42
- "@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.2.tgz",
43
- "@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.2.tgz",
44
- "@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.2.tgz",
45
- "@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.2.tgz",
46
- "@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.2.tgz",
41
+ "@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.5.tgz",
42
+ "@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.5.tgz",
43
+ "@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.5.tgz",
44
+ "@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.5.tgz",
45
+ "@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.5.tgz",
46
+ "@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.5.tgz",
47
47
  "@vitejs/plugin-vue": "6.0.7",
48
48
  "@vue-flow/core": "1.48.2",
49
49
  "archiver": "7.0.1",
@@ -18,7 +18,7 @@ const agentName = argValue('agent-name', agentId);
18
18
  const token = argValue('token', process.env.REMOTE_AGENT_TOKEN || '');
19
19
 
20
20
  if (!server) {
21
- console.error('缺少 --server,例如:npm run remote-agent -- --server http://172.16.20.116:5173');
21
+ console.error('缺少 --server,例如:npm run remote-agent -- --server http://<控制台主机地址>:5173');
22
22
  process.exit(1);
23
23
  }
24
24
 
@@ -1,4 +1,5 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { captureHistoryFrames, readAppVersion } from './history-capture';
2
3
  import { BoundedLoopTraversal } from './bounded-loop';
3
4
  import { validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
4
5
  import { adbScreenshotBase64 } from './screenshot';
@@ -49,6 +50,8 @@ type PendingVisualChangeCheck = {
49
50
  step: AppiumRecordedStepRecord;
50
51
  nodeNumber: number;
51
52
  scriptName: string;
53
+ frameStart: number;
54
+ endFrameStart: number;
52
55
  };
53
56
 
54
57
  const replayContext = new AsyncLocalStorage<{
@@ -57,8 +60,10 @@ const replayContext = new AsyncLocalStorage<{
57
60
  serverUrl: string;
58
61
  deviceId: string;
59
62
  frames: AppiumReplayFrame[];
63
+ historyEvents: AppiumReplayFrame[];
60
64
  visualChecks: AppiumReplayVisualCheck[];
61
65
  pendingVisualChecks: PendingVisualChangeCheck[];
66
+ visualStartOffsets: Map<string, number>;
62
67
  softFailureCount: number;
63
68
  flowEnded?: boolean;
64
69
  }>();
@@ -332,6 +337,10 @@ async function captureReplayFrame(
332
337
  ) {
333
338
  const context = replayContext.getStore();
334
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: '' });
335
344
  const appendFrame = (imageBase64: string) => {
336
345
  context.frames.push({
337
346
  sequence: context.frames.length + 1,
@@ -519,6 +528,7 @@ async function saveScreenshot(deviceId: string) {
519
528
  type RunStepMeta = {
520
529
  nodeNumber: number;
521
530
  scriptName: string;
531
+ frameStart?: number;
522
532
  };
523
533
 
524
534
  type RunStepResult = string | {
@@ -560,10 +570,9 @@ function findVisualFrame(
560
570
  ) {
561
571
  const matches = frames.filter((frame) => frame.nodeId === nodeId && frame.scriptName === scriptName);
562
572
  if (!matches.length) return undefined;
563
- const preferred = preferredPhase === 'before'
564
- ? matches.find((frame) => frame.phase === preferredPhase)
565
- : [...matches].reverse().find((frame) => frame.phase === preferredPhase);
566
- 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;
567
576
  }
568
577
 
569
578
  const EMPTY_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
@@ -574,12 +583,14 @@ function registerVisualChangeStep(
574
583
  ): RunStepResult {
575
584
  const config = step.visualChange;
576
585
  if (!config) throw new Error(`${step.label} 缺少画面变化检测配置`);
586
+ const context = replayContext.getStore();
577
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);
578
590
  return {
579
591
  message: `已记录画面变化基准点:${config.pairLabel || step.label}`,
580
592
  };
581
593
  }
582
- const context = replayContext.getStore();
583
594
  const endStep = {
584
595
  ...step,
585
596
  visualChange: {
@@ -587,7 +598,9 @@ function registerVisualChangeStep(
587
598
  endStepId: config.endStepId || step.id,
588
599
  },
589
600
  };
590
- 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 });
591
604
  return {
592
605
  message: `已记录画面变化对比点:${config.pairLabel || step.label}`,
593
606
  };
@@ -624,16 +637,16 @@ function processPendingVisualChecks(lines: string[]) {
624
637
  const context = replayContext.getStore();
625
638
  if (!context || !context.pendingVisualChecks.length) return;
626
639
 
627
- // Visual checks compare captured replay frames after the path finishes, so
628
- // 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.
629
642
  context.pendingVisualChecks.forEach((check) => {
630
643
  const config = check.step.visualChange;
631
644
  if (!config?.startStepId || !config.endStepId) {
632
645
  appendVisualCheckFailure(lines, check, `${check.step.label} 缺少开始节点或结束节点`);
633
646
  return;
634
647
  }
635
- const baselineFrame = findVisualFrame(context.frames, config.startStepId, check.scriptName, 'after');
636
- 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');
637
650
  if (!baselineFrame || !comparisonFrame) {
638
651
  const missing = [
639
652
  baselineFrame ? '' : '开始节点截图',
@@ -1005,12 +1018,17 @@ async function appendStepDiagnostics(lines: string[], deviceId: string, step: Ap
1005
1018
  }
1006
1019
 
1007
1020
  type ReplayStepOptions = {
1021
+ frameStart?: number;
1008
1022
  skipAppInitialization?: boolean;
1009
1023
  scriptName?: string;
1010
1024
  };
1011
1025
 
1012
- function shouldSkipLinkedScriptInitStep(options: ReplayStepOptions, step: AppiumRecordedStepRecord) {
1013
- 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'));
1014
1032
  }
1015
1033
 
1016
1034
  function linkedScriptInitSkipReason(step: AppiumRecordedStepRecord) {
@@ -1090,7 +1108,7 @@ async function replayLinearSteps(
1090
1108
  for (const [index, step] of steps.entries()) {
1091
1109
  if (replayContext.getStore()?.flowEnded) return;
1092
1110
  throwIfReplayStopped();
1093
- if (shouldSkipLinkedScriptInitStep(options, step)) {
1111
+ if (shouldSkipLinkedScriptInitStep(options, step, steps)) {
1094
1112
  lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
1095
1113
  continue;
1096
1114
  }
@@ -1104,11 +1122,13 @@ async function replayLinearSteps(
1104
1122
  const result = await runStep(sessionId, deviceId, step, {
1105
1123
  nodeNumber: index + 1,
1106
1124
  scriptName: options.scriptName || '',
1125
+ frameStart: options.frameStart,
1107
1126
  });
1108
1127
  softFailed = appendRunStepResult(lines, index + 1, result);
1109
1128
  }
1110
1129
  lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
1111
1130
  await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
1131
+ processPendingVisualChecks(lines);
1112
1132
  } catch (error) {
1113
1133
  if (isReplayStopped(error)) {
1114
1134
  lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
@@ -1144,7 +1164,7 @@ async function replayFlowSteps(
1144
1164
  traversal.visit(step);
1145
1165
  const loopContext = traversal.context(step);
1146
1166
  const frameStep = loopContext ? { ...step, label: `${step.label}(${loopContext})` } : step;
1147
- if (shouldSkipLinkedScriptInitStep(options, step)) {
1167
+ if (shouldSkipLinkedScriptInitStep(options, step, steps)) {
1148
1168
  lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
1149
1169
  index = nextIndexAfterStep(index, step);
1150
1170
  continue;
@@ -1225,11 +1245,13 @@ async function replayFlowSteps(
1225
1245
  const result = await runStep(sessionId, deviceId, step, {
1226
1246
  nodeNumber: index + 1,
1227
1247
  scriptName: options.scriptName || '',
1248
+ frameStart: options.frameStart,
1228
1249
  });
1229
1250
  softFailed = appendRunStepResult(lines, index + 1, result);
1230
1251
  }
1231
1252
  lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
1232
1253
  await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
1254
+ processPendingVisualChecks(lines);
1233
1255
  index = nextIndexAfterStep(index, step);
1234
1256
  } catch (error) {
1235
1257
  if (isReplayStopped(error)) {
@@ -1278,6 +1300,7 @@ async function replayScriptSteps(
1278
1300
  options: ReplayStepOptions = {},
1279
1301
  ) {
1280
1302
  validateLoopSteps(steps);
1303
+ options = { ...options, frameStart: replayContext.getStore()?.frames.length || 0 };
1281
1304
  let trailingLinkIndex = steps.length;
1282
1305
  while (
1283
1306
  trailingLinkIndex > 0
@@ -1325,6 +1348,7 @@ export async function replayAppiumScript(
1325
1348
  if (!script.steps.length) throw new Error('脚本没有可回放步骤');
1326
1349
 
1327
1350
  const startedAt = new Date();
1351
+ const appVersion = await readAppVersion(targetDeviceId, script.appPackage).catch(() => '');
1328
1352
  const lines: string[] = [];
1329
1353
  const pushLine = lines.push.bind(lines);
1330
1354
  lines.push = (...items: string[]) => {
@@ -1338,7 +1362,9 @@ export async function replayAppiumScript(
1338
1362
  serverUrl: configuredAppiumServerUrl(),
1339
1363
  deviceId: targetDeviceId,
1340
1364
  frames: [] as AppiumReplayFrame[],
1365
+ historyEvents: [] as AppiumReplayFrame[],
1341
1366
  visualChecks: [] as AppiumReplayVisualCheck[],
1367
+ visualStartOffsets: new Map<string, number>(),
1342
1368
  pendingVisualChecks: [] as PendingVisualChangeCheck[],
1343
1369
  softFailureCount: 0,
1344
1370
  };
@@ -1461,6 +1487,14 @@ export async function replayAppiumScript(
1461
1487
  logPath,
1462
1488
  htmlReportPath,
1463
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
+ },
1464
1498
  };
1465
1499
  });
1466
1500
  }
@@ -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
 
@@ -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
+ }
@@ -2,11 +2,13 @@
2
2
  import { computed, h, onMounted, onUnmounted, provide, reactive, shallowRef, watch } from 'vue';
3
3
  import { flowBackgroundKey, normalizeFlowBackground } from './flow-appearance';
4
4
  import { ElForm, ElFormItem, ElInputNumber, ElMessage, ElMessageBox, ElOption, ElSelect } from 'element-plus';
5
- import { Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Plus, Refresh, Upload, VideoPlay, View } from '@element-plus/icons-vue';
5
+ import { Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Plus, Refresh, Upload, VideoPlay, View, Clock } from '@element-plus/icons-vue';
6
+ import RunHistoryDialog from './RunHistoryDialog.vue';
6
7
  import type { AndroidDevice, AppPreset, DeviceAction } from '../types';
7
8
  import DevicePreviewPanel from '../components/device/DevicePreviewPanel.vue';
8
9
  import LoopSettings from './components/LoopSettings.vue';
9
10
  import BreakLoopSettings from './components/BreakLoopSettings.vue';
11
+ import VisualChangeEndPicker from './components/VisualChangeEndPicker.vue';
10
12
  import { defaultLoopConfig, enclosingLoops, breakLoopTarget, validateLoop, validateLoopSteps } from './bounded-loop';
11
13
  import {
12
14
  clearAppiumDeviceAppData,
@@ -53,7 +55,7 @@ import {
53
55
  visualRegionToBounds,
54
56
  } from './visual-change';
55
57
  import {
56
- findOpenVisualChangeStart,
58
+ findOpenVisualChangeStarts,
57
59
  nextVisualChangePairLabel,
58
60
  } from './visual-change-pairs';
59
61
  import type {
@@ -80,6 +82,7 @@ type RecorderAction = InsertAction;
80
82
 
81
83
  const WORKBENCH_TAB_STORAGE_KEY = 'android-midscene-automation:appium-workbench-tab';
82
84
  const workspaceRef = shallowRef<InstanceType<typeof RecorderWorkspace>>();
85
+ const historyScript = shallowRef<{ id: string; name: string } | null>(null);
83
86
  const AUTO_TREE_REFRESH_INTERVAL_MS = 1800;
84
87
 
85
88
  const insertableRecorderActions: RecorderAction[] = [
@@ -1266,11 +1269,6 @@ async function addAction(
1266
1269
  ) {
1267
1270
  if (recordingBusy.value) return;
1268
1271
  if (!ensureAppPackageSelected()) return;
1269
- const previousStep = typeof index === 'number' && index >= 0 ? steps.value[index] : undefined;
1270
- if (action === 'launchApp' && steps.value.some((step) => step.type === 'launchApp')) {
1271
- ElMessage.warning('启动 APP 节点只能添加一个');
1272
- return;
1273
- }
1274
1272
  if (action === 'clearAppData' && steps.value.some((step) => step.type === 'clearAppData')) {
1275
1273
  ElMessage.warning('清理 App 缓存节点只能添加一个');
1276
1274
  return;
@@ -1279,10 +1277,6 @@ async function addAction(
1279
1277
  ElMessage.warning('清理 App 缓存只能从开始节点添加');
1280
1278
  return;
1281
1279
  }
1282
- if (action === 'launchApp' && index !== -1 && previousStep?.type !== 'clearAppData') {
1283
- ElMessage.warning('启动 APP 只能从开始节点或清理 App 缓存后添加');
1284
- return;
1285
- }
1286
1280
  if (action === 'runScript') {
1287
1281
  openLinkedScriptDialog(index, branchTarget);
1288
1282
  return;
@@ -1406,11 +1400,31 @@ async function addAction(
1406
1400
  return;
1407
1401
  }
1408
1402
  if (action === 'visualChangeEnd') {
1409
- const startStep = findOpenVisualChangeStart(steps.value, index, branchTarget);
1410
- if (!startStep) {
1403
+ const candidates = findOpenVisualChangeStarts(steps.value, index, branchTarget);
1404
+ if (!candidates.length) {
1411
1405
  ElMessage.warning('当前分支没有可对应的检测画面变化开始节点');
1412
1406
  return;
1413
1407
  }
1408
+ let selectedId = candidates.length === 1 ? candidates[0].id : '';
1409
+ if (candidates.length > 1) {
1410
+ selectedId = await new Promise<string>((resolve) => { void ElMessageBox({
1411
+ title: '选择要结束的画面变化检测',
1412
+ showConfirmButton: false,
1413
+ showCancelButton: false,
1414
+ callback: () => resolve(''),
1415
+ message: h(VisualChangeEndPicker, {
1416
+ candidates: candidates.map(step => ({ id: step.id, label: step.visualChange?.pairLabel || step.label, number: steps.value.findIndex(item => item.id === step.id) + 1 })),
1417
+ onSelect: (id: string) => { resolve(id); ElMessageBox.close(); },
1418
+ }),
1419
+ }); });
1420
+ if (!selectedId) return;
1421
+ }
1422
+ // 选择期间流程可能发生变化;插入前重新检查配对,不能使用过期候选。
1423
+ const startStep = findOpenVisualChangeStarts(steps.value, index, branchTarget).find(step => step.id === selectedId);
1424
+ if (!startStep) {
1425
+ ElMessage.warning('该开始节点已结束或不再属于当前路径,请重新选择');
1426
+ return;
1427
+ }
1414
1428
  const inserted = insertStep(createVisualChangeEndStep(startStep), index, branchTarget);
1415
1429
  ElMessage.success(`已添加 ${inserted.label}`);
1416
1430
  return inserted;
@@ -1460,14 +1474,12 @@ async function addAction(
1460
1474
  return;
1461
1475
  }
1462
1476
  if (action === 'launchApp') {
1463
- const clearStepIndex = steps.value.findIndex((step) => step.type === 'clearAppData');
1464
- const insertIndex = previousStep?.type === 'clearAppData' ? index : clearStepIndex >= 0 ? clearStepIndex : index;
1465
1477
  return insertStep({
1466
1478
  id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
1467
1479
  type: 'launchApp',
1468
1480
  label: `启动 APP ${form.appPackage}`,
1469
1481
  value: form.appPackage,
1470
- }, insertIndex);
1482
+ }, index, branchTarget);
1471
1483
  }
1472
1484
  if (action === 'clearAppData') {
1473
1485
  return insertStep({
@@ -2208,6 +2220,9 @@ watch(
2208
2220
  <small>{{ script.steps.length }} 步 · {{ formatScriptTime(script.updatedAt) }}</small>
2209
2221
  </div>
2210
2222
  <div class="appium-script-list__actions">
2223
+ <el-tooltip content="历史结果对比" placement="top" :show-after="200"><span class="appium-script-icon">
2224
+ <el-button size="small" :icon="Clock" aria-label="历史结果对比" @click.stop="historyScript = { id: script.id, name: script.name }" />
2225
+ </span></el-tooltip>
2211
2226
  <el-tooltip content="加载" placement="top" :show-after="200"><span class="appium-script-icon">
2212
2227
  <el-button size="small" :icon="Document" aria-label="加载" @click.stop="loadScript(script)" />
2213
2228
  </span></el-tooltip>
@@ -2258,6 +2273,7 @@ watch(
2258
2273
  </el-card>
2259
2274
  </RecorderWorkspace>
2260
2275
 
2276
+ <RunHistoryDialog v-if="historyScript" :script-id="historyScript.id" :script-name="historyScript.name" @close="historyScript = null" />
2261
2277
  <NewScriptDialog
2262
2278
  v-if="newScriptDialogVisible"
2263
2279
  :script-name="form.name"
@@ -0,0 +1,153 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref } from 'vue';
3
+ import { Delete, Refresh } from '@element-plus/icons-vue';
4
+ import { ElMessage, ElMessageBox } from 'element-plus';
5
+ import { historyStatistics, type RunSummary, type RunDetail } from './run-history';
6
+
7
+ const props = defineProps<{ scriptId: string; scriptName: string }>();
8
+ const emit = defineEmits<{ close: [] }>();
9
+ const runs = ref<RunSummary[]>([]);
10
+ const selected = ref<RunSummary[]>([]);
11
+ const details = ref<RunDetail[]>([]);
12
+ const loading = ref(false);
13
+ const comparing = ref(false);
14
+ const version = ref('');
15
+ const device = ref('');
16
+ const error = ref('');
17
+ const nodeKey = ref('');
18
+ const logQuery = ref('');
19
+ const base = `/api/appium-recorder/scripts/${encodeURIComponent(props.scriptId)}/history`;
20
+ const labels = { passed: '通过', failed: '失败', stopped: '已终止' };
21
+ const versions = computed(() => [...new Set(runs.value.map(run => run.appVersion || '未知'))]);
22
+ const devices = computed(() => [...new Set(runs.value.map(run => run.deviceId))]);
23
+ const filtered = computed(() => runs.value.filter(run => (!version.value || (run.appVersion || '未知') === version.value) && (!device.value || run.deviceId === device.value)));
24
+ const stats = computed(() => historyStatistics(filtered.value));
25
+ const trend = computed(() => [...filtered.value].sort((a, b) => a.startedAt.localeCompare(b.startedAt)));
26
+ const maxDuration = computed(() => Math.max(1, ...trend.value.map(run => run.durationMs)));
27
+ const points = computed(() => trend.value.map((run, index) => `${20 + index * 760 / Math.max(1, trend.value.length - 1)},${130 - run.durationMs / maxDuration.value * 110}`).join(' '));
28
+ const comparisonNodes = computed(() => [...new Map(details.value.flatMap(run => run.nodes.map(node => [node.key, node] as const))).values()]);
29
+ const time = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false });
30
+ const seconds = (value: number) => `${(value / 1000).toFixed(2)}s`;
31
+
32
+ async function request<T>(url: string, method = 'GET'): Promise<T> {
33
+ const response = await fetch(url, { method });
34
+ if (!response.ok) throw new Error('历史结果请求失败');
35
+ return response.json();
36
+ }
37
+ async function load() {
38
+ loading.value = true; error.value = '';
39
+ try { runs.value = await request<RunSummary[]>(base); selected.value = []; }
40
+ catch (cause) { error.value = String(cause); }
41
+ finally { loading.value = false; }
42
+ }
43
+ async function compare() {
44
+ comparing.value = true;
45
+ try {
46
+ details.value = await Promise.all([...selected.value].sort((a, b) => a.startedAt.localeCompare(b.startedAt)).map(run => request<RunDetail>(`${base}/${encodeURIComponent(run.id)}`)));
47
+ nodeKey.value = '';
48
+ } catch (cause) { ElMessage.error(String(cause)); }
49
+ finally { comparing.value = false; }
50
+ }
51
+ async function remove() {
52
+ try { await ElMessageBox.confirm(`删除选中的 ${selected.value.length} 条历史及截图、日志快照?原始报告文件不受影响。`, '删除历史结果', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }); }
53
+ catch { return; }
54
+ loading.value = true;
55
+ try {
56
+ for (const run of selected.value) await request(`${base}/${encodeURIComponent(run.id)}`, 'DELETE');
57
+ details.value = [];
58
+ await load();
59
+ } catch (cause) { ElMessage.error(String(cause)); await load(); }
60
+ finally { loading.value = false; }
61
+ }
62
+ function nodeDuration(run: RunDetail) {
63
+ const matches = run.nodes.filter(node => node.key === nodeKey.value);
64
+ if (!matches.length) return '未执行';
65
+ if (matches.some(node => node.durationMs === null)) return '耗时不完整';
66
+ return seconds(matches.reduce((sum, node) => sum + (node.durationMs || 0), 0));
67
+ }
68
+ onMounted(load);
69
+ </script>
70
+
71
+ <template>
72
+ <el-dialog :model-value="true" :title="`${scriptName} · 历史结果对比`" width="min(1200px, 96vw)" align-center append-to-body @closed="emit('close')">
73
+ <div v-loading="loading" class="run-history">
74
+ <div class="history-toolbar">
75
+ <el-select v-model="version" clearable placeholder="全部应用版本" aria-label="应用版本"><el-option v-for="item in versions" :key="item" :value="item" /></el-select>
76
+ <el-select v-model="device" clearable placeholder="全部设备" aria-label="设备"><el-option v-for="item in devices" :key="item" :value="item" /></el-select>
77
+ <el-tooltip content="刷新历史"><el-button :icon="Refresh" aria-label="刷新历史" @click="load" /></el-tooltip>
78
+ <el-button :disabled="selected.length !== 2" :loading="comparing" @click="compare">对比两次运行</el-button>
79
+ <el-tooltip content="删除选中历史"><el-button :icon="Delete" type="danger" plain :disabled="!selected.length" aria-label="删除选中历史" @click="remove" /></el-tooltip>
80
+ </div>
81
+ <el-alert v-if="error" :title="error" type="error" :closable="false" />
82
+ <el-empty v-else-if="!runs.length && !loading" description="暂无历史结果,后续运行会自动记录" />
83
+ <template v-else>
84
+ <p>{{ filtered.length }} 次运行 · 通过率 {{ stats.passRate === null ? '—' : `${(stats.passRate * 100).toFixed(1)}%` }}(不含手动终止)</p>
85
+ <h3>耗时趋势</h3>
86
+ <svg class="history-trend" viewBox="0 0 800 160" role="img" aria-label="按时间从早到晚的运行耗时趋势">
87
+ <path d="M20 10V130H790" fill="none" stroke="#a0a8ad" />
88
+ <polyline :points="points" fill="none" stroke="#168a75" stroke-width="2" />
89
+ <circle v-for="(run, index) in trend" :key="run.id" :cx="20 + index * 760 / Math.max(1, trend.length - 1)" :cy="130 - run.durationMs / maxDuration * 110" r="4" :fill="run.status === 'failed' ? '#d14343' : '#168a75'">
90
+ <title>{{ time(run.startedAt) }} · {{ seconds(run.durationMs) }} · {{ run.appVersion || '未知版本' }}</title>
91
+ </circle>
92
+ <text x="24" y="14">{{ seconds(maxDuration) }}</text><text x="20" y="153">{{ trend[0] ? time(trend[0].startedAt) : '' }}</text><text x="780" y="153" text-anchor="end">{{ trend.length > 1 ? time(trend[trend.length - 1].startedAt) : '' }}</text>
93
+ </svg>
94
+ <el-table :data="filtered" row-key="id" max-height="270" @selection-change="selected = $event">
95
+ <el-table-column type="selection" width="42" />
96
+ <el-table-column label="运行时间" min-width="180"><template #default="{ row }">{{ time(row.startedAt) }}</template></el-table-column>
97
+ <el-table-column prop="appVersion" label="应用版本" min-width="140"><template #default="{ row }">{{ row.appVersion || '未知' }}</template></el-table-column>
98
+ <el-table-column prop="deviceId" label="设备" min-width="140" />
99
+ <el-table-column label="结果" width="85"><template #default="{ row }">{{ labels[row.status as keyof typeof labels] }}</template></el-table-column>
100
+ <el-table-column label="耗时" width="100"><template #default="{ row }">{{ seconds(row.durationMs) }}</template></el-table-column>
101
+ </el-table>
102
+ <h3>失败节点分布</h3>
103
+ <el-table :data="stats.failures" max-height="220" empty-text="暂无节点失败记录">
104
+ <el-table-column prop="label" label="节点" min-width="220" />
105
+ <el-table-column label="失败 / 到达次数" width="145"><template #default="{ row }">{{ row.failures }} / {{ row.runs }}</template></el-table-column>
106
+ <el-table-column label="观察结果" width="130"><template #default="{ row }">{{ row.runs < 2 ? '样本不足' : row.failures === row.runs ? '持续失败' : '偶发失败' }}</template></el-table-column>
107
+ <el-table-column label="首次失败 / 版本" min-width="190"><template #default="{ row }">{{ time(row.firstFailure) }} · {{ row.version || '未知' }}</template></el-table-column>
108
+ </el-table>
109
+ </template>
110
+ <section v-if="details.length === 2" class="history-comparison">
111
+ <h3>运行对比 · 总耗时差 {{ seconds(details[1].durationMs - details[0].durationMs) }}</h3>
112
+ <el-alert v-if="details[0].deviceId !== details[1].deviceId || details[0].appPackage !== details[1].appPackage" title="两次运行的设备或应用不同,耗时差不能直接归因于版本变化" type="warning" :closable="false" />
113
+ <el-select v-model="nodeKey" clearable filterable placeholder="全部节点截图" aria-label="对比节点"><el-option v-for="node in comparisonNodes" :key="node.key" :label="node.label" :value="node.key" /></el-select>
114
+ <div class="comparison-columns">
115
+ <section v-for="run in details" :key="run.id">
116
+ <h4>{{ time(run.startedAt) }} · {{ labels[run.status] }}</h4>
117
+ <p>{{ run.appVersion || '未知版本' }} · {{ run.deviceId }} · {{ seconds(run.durationMs) }}</p>
118
+ <p v-if="nodeKey">节点累计耗时(含截图开销):{{ nodeDuration(run) }}</p>
119
+ <p v-if="!run.frames.some(frame => !nodeKey || frame.key === nodeKey)">未捕获到对应截图</p>
120
+ <div class="history-frames">
121
+ <figure v-for="(frame, index) in run.frames.filter(frame => !nodeKey || frame.key === nodeKey)" :key="index">
122
+ <figcaption>{{ frame.label }} · {{ frame.phase === 'before' ? '执行前' : frame.phase === 'after' ? '执行后' : frame.phase }} · {{ time(frame.capturedAt) }}</figcaption>
123
+ <el-image :src="frame.imageUrl" :preview-src-list="[frame.imageUrl]" preview-teleported fit="contain" loading="lazy" />
124
+ </figure>
125
+ </div>
126
+ <h4>节点结果</h4><ul><li v-for="(node, index) in run.nodes.filter(node => !nodeKey || node.key === nodeKey)" :key="index">{{ node.label }} · {{ node.failed ? '失败' : '完成' }} · {{ node.durationMs === null ? '耗时未知' : seconds(node.durationMs) }}</li></ul>
127
+ <h4>关键日志 / 完整日志</h4><el-input v-model="logQuery" clearable placeholder="日志关键字,如 stageLog" aria-label="日志关键字" /><pre>{{ logQuery ? run.output.split('\n').filter(line => line.toLowerCase().includes(logQuery.toLowerCase())).join('\n') || '无匹配日志' : run.output }}</pre>
128
+ </section>
129
+ </div>
130
+ </section>
131
+ </div>
132
+ </el-dialog>
133
+ </template>
134
+
135
+ <style scoped>
136
+ .run-history { max-height: 78vh; overflow: auto; }
137
+ .history-toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
138
+ .history-toolbar .el-select { width: 190px; }
139
+ .history-trend { width: 100%; height: 160px; background: #f3faf7; }
140
+ .history-trend text { font-size: 12px; fill: #52615b; }
141
+ h3 { font-size: 15px; margin: 18px 0 10px; }
142
+ h4 { font-size: 14px; margin: 12px 0 8px; }
143
+ .history-comparison > .el-select { width: 100%; }
144
+ .comparison-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; }
145
+ .comparison-columns section { min-width: 0; overflow-wrap: anywhere; }
146
+ .history-frames { max-height: 360px; overflow: auto; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
147
+ figure { margin: 0; }
148
+ figcaption { font-size: 12px; }
149
+ .el-image { width: 100%; height: 150px; }
150
+ pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 280px; overflow: auto; background: #f1f5f4; padding: 10px; font-size: 12px; }
151
+ ul { max-height: 160px; overflow: auto; padding-left: 20px; }
152
+ @media (max-width: 650px) { .comparison-columns { grid-template-columns: minmax(0, 1fr); } }
153
+ </style>
@@ -99,6 +99,7 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
99
99
  {
100
100
  title: '设备操作',
101
101
  actions: [
102
+ { type: 'launchApp', label: '启动 App' },
102
103
  { type: 'keyBack', label: '系统返回' },
103
104
  { type: 'keyHome', label: 'Home 键' },
104
105
  { type: 'openGallery', label: '启动相册' },
@@ -123,8 +124,8 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
123
124
  { type: 'assertText', label: '断言文本' },
124
125
  { type: 'waitDisappear', label: '等待元素消失' },
125
126
  { type: 'waitActivity', label: '等待 Activity' },
126
- { type: 'visualChangeStart', label: '检测画面变化开始节点' },
127
- { type: 'visualChangeEnd', label: '检测画面变化结束节点' },
127
+ { type: 'visualChangeStart', label: '检测画面变化开始' },
128
+ { type: 'visualChangeEnd', label: '检测画面变化结束' },
128
129
  ],
129
130
  },
130
131
  {
@@ -116,10 +116,9 @@ const insertActionGroups: FlowActionGroup[] = [
116
116
 
117
117
  const mainActionGroups = insertActionGroups.map((group) => ({
118
118
  ...group,
119
- actions: group.actions.filter((action) => action.type !== 'launchApp' && action.type !== 'clearAppData'),
119
+ actions: group.actions.filter((action) => action.type !== 'clearAppData'),
120
120
  }));
121
121
  const startActionGroups = insertActionGroups;
122
- const hasLaunchAppStep = computed(() => props.steps.some((step) => step.type === 'launchApp'));
123
122
  const hasClearAppDataStep = computed(() => props.steps.some((step) => step.type === 'clearAppData'));
124
123
 
125
124
  function isDescendantStep(descendantIndex: number, ancestorIndex: number) {
@@ -247,8 +246,7 @@ function isInsertActionDisabled(action: InsertAction) {
247
246
  }
248
247
 
249
248
  function isStartActionDisabled(action: InsertAction) {
250
- return (action === 'launchApp' && hasLaunchAppStep.value)
251
- || (action === 'clearAppData' && hasClearAppDataStep.value)
249
+ return (action === 'clearAppData' && hasClearAppDataStep.value)
252
250
  || isInsertActionDisabled(action);
253
251
  }
254
252
 
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ defineProps<{ candidates: { id: string; label: string; number: number }[] }>();
3
+ const emit = defineEmits<{ select: [id: string] }>();
4
+ </script>
5
+
6
+ <template>
7
+ <div class="visual-end-picker" aria-label="可结束的画面变化检测">
8
+ <button v-for="item in candidates" :key="item.id" type="button" @click="emit('select', item.id)">
9
+ <strong>{{ item.label }}</strong>
10
+ <span>开始节点 #{{ item.number }}</span>
11
+ </button>
12
+ </div>
13
+ </template>
14
+
15
+ <style scoped>
16
+ .visual-end-picker { display: grid; gap: 4px; max-height: 320px; overflow: auto; }
17
+ .visual-end-picker button { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 16px; width: 100%; padding: 10px 12px; border: 0; border-radius: 4px; background: transparent; color: var(--el-text-color-primary); font: inherit; text-align: left; cursor: pointer; }
18
+ .visual-end-picker button:hover, .visual-end-picker button:focus-visible { background: var(--el-fill-color-light); outline: 2px solid var(--el-color-primary); outline-offset: -2px; }
19
+ .visual-end-picker strong { font-size: 14px; overflow-wrap: anywhere; }
20
+ .visual-end-picker span { font-size: 12px; color: var(--el-text-color-secondary); }
21
+ </style>
@@ -361,11 +361,6 @@ export function buildFlowGraph(
361
361
  ) => {
362
362
  const id = insertNodeId(afterIndex, branch, condition?.step.id);
363
363
  const isStartInsert = afterIndex < 0 && !branch;
364
- const previousStep = afterIndex >= 0 ? steps[afterIndex] : undefined;
365
- const canLaunchAfterClearAppData = !isStartInsert
366
- && !branch
367
- && previousStep?.type === 'clearAppData'
368
- && !steps.some((step) => step.type === 'launchApp');
369
364
  addNode({
370
365
  id,
371
366
  type: 'flow-node',
@@ -381,9 +376,7 @@ export function buildFlowGraph(
381
376
  kind: 'insert',
382
377
  actionGroups: isStartInsert
383
378
  ? options.startActionGroups
384
- : canLaunchAfterClearAppData
385
- ? withLaunchAppAction(options.mainActionGroups)
386
- : options.mainActionGroups,
379
+ : withLaunchAppAction(options.mainActionGroups),
387
380
  afterIndex,
388
381
  branch,
389
382
  branchLabel: branch && condition ? flowBranchLabel(condition.step, branch) : undefined,
@@ -0,0 +1,50 @@
1
+ export type HistoryNode = {
2
+ key: string;
3
+ label: string;
4
+ failed: boolean;
5
+ durationMs: number | null;
6
+ };
7
+
8
+ export type RunSummary = {
9
+ id: string;
10
+ scriptId: string;
11
+ scriptName: string;
12
+ appPackage: string;
13
+ appVersion: string;
14
+ deviceId: string;
15
+ startedAt: string;
16
+ durationMs: number;
17
+ status: 'passed' | 'failed' | 'stopped';
18
+ nodes: HistoryNode[];
19
+ };
20
+
21
+ export type RunDetail = RunSummary & {
22
+ output: string;
23
+ frames: { key: string; label: string; phase: string; capturedAt: string; imageUrl: string }[];
24
+ };
25
+
26
+ // 分母只计算实际到达该节点的运行;未执行的分支不能当作通过。
27
+ export function historyStatistics(runs: RunSummary[]) {
28
+ const completed = runs.filter(run => run.status !== 'stopped');
29
+ const nodes = new Map<string, { key: string; label: string; runs: number; failures: number; firstFailure: string; version: string }>();
30
+ for (const run of [...completed].sort((a, b) => a.startedAt.localeCompare(b.startedAt))) {
31
+ const unique = new Map<string, HistoryNode>();
32
+ for (const node of run.nodes) {
33
+ const prior = unique.get(node.key);
34
+ unique.set(node.key, { ...node, failed: node.failed || Boolean(prior?.failed) });
35
+ }
36
+ for (const node of unique.values()) {
37
+ const item = nodes.get(node.key) || { key: node.key, label: node.label, runs: 0, failures: 0, firstFailure: '', version: '' };
38
+ item.runs++;
39
+ if (node.failed) {
40
+ item.failures++;
41
+ if (!item.firstFailure) { item.firstFailure = run.startedAt; item.version = run.appVersion; }
42
+ }
43
+ nodes.set(node.key, item);
44
+ }
45
+ }
46
+ return {
47
+ passRate: completed.length ? completed.filter(run => run.status === 'passed').length / completed.length : null,
48
+ failures: [...nodes.values()].filter(node => node.failures).sort((a, b) => b.failures - a.failures),
49
+ };
50
+ }
@@ -93,10 +93,6 @@ export function branchPathForInsert(
93
93
  : [];
94
94
  }
95
95
 
96
- function sameBranchPath(left: string[], right: string[]) {
97
- return left.length === right.length && left.every((value, index) => value === right[index]);
98
- }
99
-
100
96
  function branchPathContains(parent: string[], child: string[]) {
101
97
  return parent.length <= child.length && parent.every((value, index) => value === child[index]);
102
98
  }
@@ -118,19 +114,21 @@ function isEndForStart(endStep: AppiumRecordedStep, startStep: AppiumRecordedSte
118
114
  );
119
115
  }
120
116
 
121
- export function findOpenVisualChangeStart(
117
+ export function findOpenVisualChangeStarts(
122
118
  steps: AppiumRecordedStep[],
123
119
  index?: number,
124
120
  branchTarget?: VisualChangeBranchTarget,
125
121
  ) {
126
122
  const insertPath = branchPathForInsert(steps, index, branchTarget);
127
123
  const insertIndex = typeof index === 'number' ? index : steps.length - 1;
128
- // A start marker may be closed once per concrete branch path. This allows a
129
- // root start before a condition to have independent yes/no end markers, while
130
- // preventing a yes-branch start from pairing with a no-branch end.
124
+ // 祖先或子分支中的结束节点可能与新节点在同一路径执行,不能重复关闭。
125
+ // 互斥的兄弟分支不包含彼此,仍允许分别结束同一个开始节点。
131
126
  const hasEndInInsertBranch = (startStep: AppiumRecordedStep) => steps.some((step) => (
132
127
  isEndForStart(step, startStep)
133
- && sameBranchPath(branchPathForStep(steps, step), insertPath)
128
+ && (
129
+ branchPathContains(branchPathForStep(steps, step), insertPath)
130
+ || branchPathContains(insertPath, branchPathForStep(steps, step))
131
+ )
134
132
  ));
135
133
 
136
134
  return steps
@@ -143,5 +141,13 @@ export function findOpenVisualChangeStart(
143
141
  ))
144
142
  .sort((left, right) => (
145
143
  right.path.length - left.path.length || right.stepIndex - left.stepIndex
146
- ))[0]?.step;
144
+ )).map(({ step }) => step);
145
+ }
146
+
147
+ export function findOpenVisualChangeStart(
148
+ steps: AppiumRecordedStep[],
149
+ index?: number,
150
+ branchTarget?: VisualChangeBranchTarget,
151
+ ) {
152
+ return findOpenVisualChangeStarts(steps, index, branchTarget)[0];
147
153
  }