android-midscene-automation 0.1.30 → 0.1.31

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 (33) hide show
  1. package/CHANGELOG.md +7 -26
  2. package/README.md +7 -26
  3. package/index.html +1 -1
  4. package/package.json +7 -7
  5. package/server/appium-recorder/appium-runner.ts +49 -29
  6. package/server/appium-recorder/bounded-loop.ts +118 -0
  7. package/server/appium-recorder/repository.ts +9 -0
  8. package/src/appium-recorder/AppiumPage.vue +82 -38
  9. package/src/appium-recorder/bounded-loop.ts +66 -0
  10. package/src/appium-recorder/components/BreakLoopSettings.vue +29 -0
  11. package/src/appium-recorder/components/ComponentInspector.vue +26 -0
  12. package/src/appium-recorder/components/ComponentTree.vue +24 -4
  13. package/src/appium-recorder/components/FlowCanvas.vue +19 -1
  14. package/src/appium-recorder/components/FlowNodeCard.vue +6 -3
  15. package/src/appium-recorder/components/FlowNodeSearch.vue +62 -0
  16. package/src/appium-recorder/components/FlowStepEditor.vue +9 -4
  17. package/src/appium-recorder/components/LoopSettings.vue +37 -0
  18. package/src/appium-recorder/components/NestedConditionBranches.vue +8 -1
  19. package/src/appium-recorder/components/NodeDetail.vue +30 -6
  20. package/src/appium-recorder/components/RecordedSteps.vue +22 -0
  21. package/src/appium-recorder/components/RecorderWorkspace.vue +132 -0
  22. package/src/appium-recorder/flow-copy.ts +1 -0
  23. package/src/appium-recorder/flow-graph.ts +3 -1
  24. package/src/appium-recorder/flow-labels.ts +7 -0
  25. package/src/appium-recorder/flow-merge.ts +3 -2
  26. package/src/appium-recorder/flow-normalize.ts +1 -0
  27. package/src/appium-recorder/flow-search.ts +29 -0
  28. package/src/appium-recorder/node-locators.ts +25 -0
  29. package/src/appium-recorder/tree.ts +2 -5
  30. package/src/appium-recorder/types.ts +7 -0
  31. package/src/components/device/DevicePreviewPanel.vue +62 -10
  32. package/src/style.css +24 -7
  33. package/src/ui-refresh.css +40 -0
package/CHANGELOG.md CHANGED
@@ -1,11 +1,16 @@
1
1
  # 更新记录
2
2
 
3
- ## v0.1.30
3
+ ## v0.1.31
4
+ - 组件详情展示 Resource ID、Accessibility ID、XPath 和 UiAutomator 四种定位内容,支持分别复制,方便配置循环退出条件;缺少对应属性时显示未提供。
5
+ - “流程控制”新增有界循环及退出循环:支持固定次数、元素出现或消失时退出,必须设置最大执行次数;循环体支持嵌套判断、合流和连接脚本。退出节点可选择当前或外层循环,退出外层时同时结束内部循环并直接执行目标循环结束路径,日志及 HTML 报告标注轮次和退出目标。
6
+ - 新增流程节点搜索,支持按名称、操作类型、定位内容和日志关键字筛选,展示所属分支并在多个结果间切换;普通画布与流程总览支持定位高亮,不改变节点布局及批量选中状态。
7
+ - Midscene 相关模块统一从 1.12.0 升级至 1.12.2,包含 Android、Android Playground、Core、Playground、Shared 和 Web。
8
+ - 录制工作区支持拖拽调整设备预览、组件树栏宽,自动保存布局并支持恢复默认。
4
9
 
10
+ ## v0.1.30
5
11
  - 新增节点批量删除功能,支持多选节点,选中节点及所属子节点以柔和红色背景和浅色文字标记,确认后统一删除;支持普通与放大视图同步操作。
6
12
 
7
13
  ## v0.1.29
8
-
9
14
  - 判断节点新增“合并分支”,支持选择一侧公共流程起点,将两侧后续汇入同一段居中流程;另一侧可保留独有操作或确认删除重复后缀,支持嵌套判断及各类分支,公共节点只执行一次且不随单侧节点删除。
10
15
  - 将 Checkbox、RadioButton 判断入口合并为“判断勾选”,新增 Switch 支持;添加和回放时自动校验组件类型及可勾选属性,读取实时 checked 状态进入 true / false 分支,不改变控件状态,兼容已有独立判断节点。
11
16
  - “流程控制”新增终止流程节点,执行到该节点时主动结束本次回放,不再执行后续节点或连接脚本;子脚本触发时也结束整个流程,保留执行日志和报告,不作为执行失败。
@@ -13,7 +18,6 @@
13
18
  - Appium 配置新增流程背景色设置,默认沿用浅绿色,支持预选颜色、颜色选择器、#RGB / #RRGGBB 输入校验和恢复默认;保存后持久化,普通画布、放大视图和连接脚本预览统一应用。
14
19
 
15
20
  ## v0.1.28
16
-
17
21
  - Appium 配置中的流程背景色独立成区,与节点模型配置分开展示,保留预选颜色、自定义颜色及保存入口。
18
22
  - “组件操作”新增文字点击分支节点,无需预选元素,支持精准匹配和模糊匹配原生组件文字;匹配并点击后进入“匹配到文字”,等待超时未找到则进入“未匹配到文字”。支持编辑文字和超时,多元素匹配及点击异常明确报错,日志和报告保留分支结果。
19
23
  - “流程控制”新增输出日志节点,支持设置日志内容和自定义关键字,默认按 `stageLog:内容` 输出;支持添加后编辑,回放实时输出、日志文件及报告均保留日志内容,多行内容逐行添加关键字。
@@ -22,7 +26,6 @@
22
26
  - 新增原生 Checkbox 和 RadioButton 状态判断节点,添加时校验选中元素类型,回放时读取 `checked` 属性,按 `true / false` 进入对应分支,不改变勾选状态;组件详情显示原生勾选属性,回放日志和报告记录判断结果。
23
27
 
24
28
  ## v0.1.27
25
-
26
29
  - npx android-midscene-automation 启动时默认使用固定的系统用户数据目录保存脚本、配置、报告和运行态文件,并在启动时尝试迁移旧版启动目录下的数据,降低升级后脚本或配置“不见了”的风险。
27
30
  - 新增脚本列表复制和重命名能力,可直接生成“原文件名 Copy”副本并修改脚本名称,便于复用已有录制流程。
28
31
  - 新增检测画面变化能力,支持框选设备预览区域或使用当前选中元素作为检测区域,通过“检测画面变化N-开始节点 / 结束节点”对同一分支内的前后截图进行差异对比。
@@ -32,34 +35,28 @@
32
35
  - 回放报告截图和截图节点改为使用 ADB 截图,避免 Appium /screenshot 超时后堵塞 UiAutomator2 命令队列;Appium 请求超时时也会显示更准确的超时提示。
33
36
 
34
37
  ## v0.1.26
35
-
36
38
  - 修复流程节点复制与粘贴问题,避免 `structuredClone` 失败、粘贴后原节点消失、分支插入位置错误和复制后相对位置偏移。
37
39
  - 修复判断分支已有节点时入口添加按钮缺失的问题,分支入口现在会保留可插入节点,并正确连接到首个分支节点。
38
40
  - 滑动操作支持在添加弹窗和节点配置中修改起点、终点坐标及滑动时长。
39
41
  - 长按操作支持设置长按时间,并在节点卡片中显示长按坐标和时长。
40
42
 
41
43
  ## v0.1.25
42
-
43
44
  - Android 设备预览恢复使用 scrcpy 实时流,优化实时流参数,减少 H.264 丢帧导致的花屏;刷新画面时会重启底层 Playground 预览流并等待会话就绪。
44
45
  - 测试脚本生成新增提示词模板,可在通用提示词、广告测试、表单填写、聊天对话、播放器测试、搜索列表、订单支付、权限弹窗、个人设置和 H5/WebView 等场景间切换,并支持编辑或上传当前原始 Prompt。
45
46
  - 优化 AI 生成流程,支持后台处理和终止生成;生成中锁定原始 Prompt,再次点击“AI 生成”可恢复进度弹窗,终止后不再弹出额外提示。
46
47
  - 优化脚本生成提示词策略,区分广告、播放器、弹窗等被测目标与无关遮挡内容,登录流程增加登录后遮挡弹窗处理和无遮挡首页成功判定。
47
48
 
48
49
  ## v0.1.24
49
-
50
50
  - 参数配置的运行配置新增保存校验,Android SDK 路径必须包含 `platform-tools/adb`,回放报告目录必须填写已存在且可写入的绝对路径。
51
51
  - Midscene 自定义提供方的 `Model Name` 和 `Model Family` 改为输入框,`Base URL` 匹配已知提供方时自动填充模型名称和模型系列,并在字段旁新增官方配置说明入口。
52
52
 
53
53
  ## v0.1.23
54
-
55
54
  - 常见问题拆分为独立 `FAQ.md` 文档,并在 README 的“功能文档”中增加跳转入口。
56
55
 
57
56
  ## v0.1.22
58
-
59
57
  - 升级 Midscene 相关依赖到 `v1.12.0`
60
58
 
61
59
  ## v0.1.21
62
-
63
60
  - 录制步骤流程图升级为独立画布与节点卡片视图,优化节点内容排版、操作图标、选中态、批量复制和还原位置体验。
64
61
  - 统一普通流程线、操作按钮连线和判断分支线条样式,按节点实际高度计算连接位置,修复线条颜色不一致、断裂、多余线条、节点重叠和分支错位问题。
65
62
  - 优化判断分支布局,统一“是 / 否”分支的 T 形连接样式,避免分支节点堆叠、左右分支连接错乱及多出第三分支。
@@ -69,36 +66,29 @@
69
66
  - 修复放大面板后点击操作按钮或节点配置会重置缩放的问题,保持当前画布大小和位置。
70
67
 
71
68
  ## v0.1.20
72
-
73
69
  - Appium 流程支持单节点、连续节点及完整判断子流程的复制与粘贴。
74
70
  - 优化判断分支布局和流程总览,支持在总览中编辑完整流程。
75
71
  - Appium session 与“启动 APP”节点解耦,并避免将桌面、设置等系统 Activity 误识别为脚本入口。
76
72
  - 修复复杂判断脚本加载失败及加载后无法进入“当前录制”的问题。
77
73
 
78
74
  ## v0.1.19
79
-
80
75
  - 回放输出支持保留完整 Appium 服务日志,并可在执行过程中手动终止。
81
76
  - 新增单文件 HTML 截图回放报告,提供时间轴、步骤导航和连续播放效果。
82
77
 
83
78
  ## v0.1.18
84
-
85
79
  - 使用说明和更新记录自动嵌入 README,可直接在 npm 页面渲染查看。
86
80
 
87
81
  ## v0.1.17
88
-
89
82
  - 新增在线文档查看入口,支持渲染使用说明和更新记录。
90
83
 
91
84
  ## v0.1.16
92
-
93
85
  - 优化参数配置页面布局,将预设 App 参数归入运行配置区域。
94
86
 
95
87
  ## v0.1.15
96
-
97
88
  - README 新增 Midscene 与 Appium 两种测试方式及环境要求说明。
98
89
  - 明确 Midscene 需要配置模型,Appium 无需模型即可运行。
99
90
 
100
91
  ## v0.1.14
101
-
102
92
  - 参数配置支持指定 Android SDK 和回放报告目录,并在回放前检查 Android SDK。
103
93
  - 回放完成后生成包含节点配置、执行结果和日志的 Markdown 报告。
104
94
  - 脚本列表支持 JSON 脚本导入和下载。
@@ -108,25 +98,20 @@
108
98
  - 新增节点备注、未保存提醒和页面状态持久化。
109
99
 
110
100
  ## v0.1.13
111
-
112
101
  - 修复历史脚本新增节点后无法覆盖保存的问题。
113
102
  - 修复连接脚本未按顺序继续执行的问题。
114
103
 
115
104
  ## v0.1.12
116
-
117
105
  - “启动 APP”统一为流程开始节点操作,每个脚本最多添加一次。
118
106
  - 连接脚本回放时自动跳过子脚本的重复启动操作。
119
107
 
120
108
  ## v0.1.11
121
-
122
109
  - 修复 npm 页面中的更新记录访问链接。
123
110
 
124
111
  ## v0.1.10
125
-
126
112
  - npm 包新增项目更新记录,并清理未随包发布的内部文档链接。
127
113
 
128
114
  ## v0.1.9
129
-
130
115
  - 录制步骤升级为可拖动、缩放和编辑的流程图,支持“是 / 否”判断分支。
131
116
  - 支持在流程任意位置插入操作,并编辑节点、定位器、超时和输入内容。
132
117
  - 支持 Activity 跳转检测和单 Activity 脚本录制保护。
@@ -134,23 +119,19 @@
134
119
  - 新增脚本管理列表,可加载、删除和回放已录制脚本。
135
120
 
136
121
  ## v0.1.8
137
-
138
122
  - 新增 selector 唯一性检测和父级上下文定位,解决多个组件共用同一 ID 的问题。
139
123
  - 回放支持多级定位和坐标兜底,并记录页面检查点与完整失败诊断信息。
140
124
 
141
125
  ## v0.1.7
142
-
143
126
  - 新增存在则点击、输入、清空、返回等可选操作。
144
127
  - 新增等待出现、判断存在和等待消失等等待与判断能力。
145
128
 
146
129
  ## v0.1.6
147
-
148
130
  - 新增可复用设备预览,支持点击画面选择组件树节点和刷新实时画面。
149
131
  - 组件树支持滚动浏览并展示当前 Activity。
150
132
  - 录制操作统一通过“添加操作”菜单选择。
151
133
 
152
134
  ## v0.1.5
153
-
154
135
  - 首次提供 Appium 无模型录制与回放能力。
155
136
  - 支持从组件树录制点击、输入、断言和延时,并保存为可回放脚本。
156
137
  - App 包名与参数配置中的预设 App 统一管理。
package/README.md CHANGED
@@ -1229,12 +1229,17 @@ Linux: ~/.local/share/android-midscene-automation
1229
1229
 
1230
1230
  # 项目更新记录
1231
1231
 
1232
- ## v0.1.30
1232
+ ## v0.1.31
1233
+ - 组件详情展示 Resource ID、Accessibility ID、XPath 和 UiAutomator 四种定位内容,支持分别复制,方便配置循环退出条件;缺少对应属性时显示未提供。
1234
+ - “流程控制”新增有界循环及退出循环:支持固定次数、元素出现或消失时退出,必须设置最大执行次数;循环体支持嵌套判断、合流和连接脚本。退出节点可选择当前或外层循环,退出外层时同时结束内部循环并直接执行目标循环结束路径,日志及 HTML 报告标注轮次和退出目标。
1235
+ - 新增流程节点搜索,支持按名称、操作类型、定位内容和日志关键字筛选,展示所属分支并在多个结果间切换;普通画布与流程总览支持定位高亮,不改变节点布局及批量选中状态。
1236
+ - Midscene 相关模块统一从 1.12.0 升级至 1.12.2,包含 Android、Android Playground、Core、Playground、Shared 和 Web。
1237
+ - 录制工作区支持拖拽调整设备预览、组件树栏宽,自动保存布局并支持恢复默认。
1233
1238
 
1239
+ ## v0.1.30
1234
1240
  - 新增节点批量删除功能,支持多选节点,选中节点及所属子节点以柔和红色背景和浅色文字标记,确认后统一删除;支持普通与放大视图同步操作。
1235
1241
 
1236
1242
  ## v0.1.29
1237
-
1238
1243
  - 判断节点新增“合并分支”,支持选择一侧公共流程起点,将两侧后续汇入同一段居中流程;另一侧可保留独有操作或确认删除重复后缀,支持嵌套判断及各类分支,公共节点只执行一次且不随单侧节点删除。
1239
1244
  - 将 Checkbox、RadioButton 判断入口合并为“判断勾选”,新增 Switch 支持;添加和回放时自动校验组件类型及可勾选属性,读取实时 checked 状态进入 true / false 分支,不改变控件状态,兼容已有独立判断节点。
1240
1245
  - “流程控制”新增终止流程节点,执行到该节点时主动结束本次回放,不再执行后续节点或连接脚本;子脚本触发时也结束整个流程,保留执行日志和报告,不作为执行失败。
@@ -1242,7 +1247,6 @@ Linux: ~/.local/share/android-midscene-automation
1242
1247
  - Appium 配置新增流程背景色设置,默认沿用浅绿色,支持预选颜色、颜色选择器、#RGB / #RRGGBB 输入校验和恢复默认;保存后持久化,普通画布、放大视图和连接脚本预览统一应用。
1243
1248
 
1244
1249
  ## v0.1.28
1245
-
1246
1250
  - Appium 配置中的流程背景色独立成区,与节点模型配置分开展示,保留预选颜色、自定义颜色及保存入口。
1247
1251
  - “组件操作”新增文字点击分支节点,无需预选元素,支持精准匹配和模糊匹配原生组件文字;匹配并点击后进入“匹配到文字”,等待超时未找到则进入“未匹配到文字”。支持编辑文字和超时,多元素匹配及点击异常明确报错,日志和报告保留分支结果。
1248
1252
  - “流程控制”新增输出日志节点,支持设置日志内容和自定义关键字,默认按 `stageLog:内容` 输出;支持添加后编辑,回放实时输出、日志文件及报告均保留日志内容,多行内容逐行添加关键字。
@@ -1251,7 +1255,6 @@ Linux: ~/.local/share/android-midscene-automation
1251
1255
  - 新增原生 Checkbox 和 RadioButton 状态判断节点,添加时校验选中元素类型,回放时读取 `checked` 属性,按 `true / false` 进入对应分支,不改变勾选状态;组件详情显示原生勾选属性,回放日志和报告记录判断结果。
1252
1256
 
1253
1257
  ## v0.1.27
1254
-
1255
1258
  - npx android-midscene-automation 启动时默认使用固定的系统用户数据目录保存脚本、配置、报告和运行态文件,并在启动时尝试迁移旧版启动目录下的数据,降低升级后脚本或配置“不见了”的风险。
1256
1259
  - 新增脚本列表复制和重命名能力,可直接生成“原文件名 Copy”副本并修改脚本名称,便于复用已有录制流程。
1257
1260
  - 新增检测画面变化能力,支持框选设备预览区域或使用当前选中元素作为检测区域,通过“检测画面变化N-开始节点 / 结束节点”对同一分支内的前后截图进行差异对比。
@@ -1261,34 +1264,28 @@ Linux: ~/.local/share/android-midscene-automation
1261
1264
  - 回放报告截图和截图节点改为使用 ADB 截图,避免 Appium /screenshot 超时后堵塞 UiAutomator2 命令队列;Appium 请求超时时也会显示更准确的超时提示。
1262
1265
 
1263
1266
  ## v0.1.26
1264
-
1265
1267
  - 修复流程节点复制与粘贴问题,避免 `structuredClone` 失败、粘贴后原节点消失、分支插入位置错误和复制后相对位置偏移。
1266
1268
  - 修复判断分支已有节点时入口添加按钮缺失的问题,分支入口现在会保留可插入节点,并正确连接到首个分支节点。
1267
1269
  - 滑动操作支持在添加弹窗和节点配置中修改起点、终点坐标及滑动时长。
1268
1270
  - 长按操作支持设置长按时间,并在节点卡片中显示长按坐标和时长。
1269
1271
 
1270
1272
  ## v0.1.25
1271
-
1272
1273
  - Android 设备预览恢复使用 scrcpy 实时流,优化实时流参数,减少 H.264 丢帧导致的花屏;刷新画面时会重启底层 Playground 预览流并等待会话就绪。
1273
1274
  - 测试脚本生成新增提示词模板,可在通用提示词、广告测试、表单填写、聊天对话、播放器测试、搜索列表、订单支付、权限弹窗、个人设置和 H5/WebView 等场景间切换,并支持编辑或上传当前原始 Prompt。
1274
1275
  - 优化 AI 生成流程,支持后台处理和终止生成;生成中锁定原始 Prompt,再次点击“AI 生成”可恢复进度弹窗,终止后不再弹出额外提示。
1275
1276
  - 优化脚本生成提示词策略,区分广告、播放器、弹窗等被测目标与无关遮挡内容,登录流程增加登录后遮挡弹窗处理和无遮挡首页成功判定。
1276
1277
 
1277
1278
  ## v0.1.24
1278
-
1279
1279
  - 参数配置的运行配置新增保存校验,Android SDK 路径必须包含 `platform-tools/adb`,回放报告目录必须填写已存在且可写入的绝对路径。
1280
1280
  - Midscene 自定义提供方的 `Model Name` 和 `Model Family` 改为输入框,`Base URL` 匹配已知提供方时自动填充模型名称和模型系列,并在字段旁新增官方配置说明入口。
1281
1281
 
1282
1282
  ## v0.1.23
1283
-
1284
1283
  - 常见问题拆分为独立 `FAQ.md` 文档,并在 README 的“功能文档”中增加跳转入口。
1285
1284
 
1286
1285
  ## v0.1.22
1287
-
1288
1286
  - 升级 Midscene 相关依赖到 `v1.12.0`
1289
1287
 
1290
1288
  ## v0.1.21
1291
-
1292
1289
  - 录制步骤流程图升级为独立画布与节点卡片视图,优化节点内容排版、操作图标、选中态、批量复制和还原位置体验。
1293
1290
  - 统一普通流程线、操作按钮连线和判断分支线条样式,按节点实际高度计算连接位置,修复线条颜色不一致、断裂、多余线条、节点重叠和分支错位问题。
1294
1291
  - 优化判断分支布局,统一“是 / 否”分支的 T 形连接样式,避免分支节点堆叠、左右分支连接错乱及多出第三分支。
@@ -1298,36 +1295,29 @@ Linux: ~/.local/share/android-midscene-automation
1298
1295
  - 修复放大面板后点击操作按钮或节点配置会重置缩放的问题,保持当前画布大小和位置。
1299
1296
 
1300
1297
  ## v0.1.20
1301
-
1302
1298
  - Appium 流程支持单节点、连续节点及完整判断子流程的复制与粘贴。
1303
1299
  - 优化判断分支布局和流程总览,支持在总览中编辑完整流程。
1304
1300
  - Appium session 与“启动 APP”节点解耦,并避免将桌面、设置等系统 Activity 误识别为脚本入口。
1305
1301
  - 修复复杂判断脚本加载失败及加载后无法进入“当前录制”的问题。
1306
1302
 
1307
1303
  ## v0.1.19
1308
-
1309
1304
  - 回放输出支持保留完整 Appium 服务日志,并可在执行过程中手动终止。
1310
1305
  - 新增单文件 HTML 截图回放报告,提供时间轴、步骤导航和连续播放效果。
1311
1306
 
1312
1307
  ## v0.1.18
1313
-
1314
1308
  - 使用说明和更新记录自动嵌入 README,可直接在 npm 页面渲染查看。
1315
1309
 
1316
1310
  ## v0.1.17
1317
-
1318
1311
  - 新增在线文档查看入口,支持渲染使用说明和更新记录。
1319
1312
 
1320
1313
  ## v0.1.16
1321
-
1322
1314
  - 优化参数配置页面布局,将预设 App 参数归入运行配置区域。
1323
1315
 
1324
1316
  ## v0.1.15
1325
-
1326
1317
  - README 新增 Midscene 与 Appium 两种测试方式及环境要求说明。
1327
1318
  - 明确 Midscene 需要配置模型,Appium 无需模型即可运行。
1328
1319
 
1329
1320
  ## v0.1.14
1330
-
1331
1321
  - 参数配置支持指定 Android SDK 和回放报告目录,并在回放前检查 Android SDK。
1332
1322
  - 回放完成后生成包含节点配置、执行结果和日志的 Markdown 报告。
1333
1323
  - 脚本列表支持 JSON 脚本导入和下载。
@@ -1337,25 +1327,20 @@ Linux: ~/.local/share/android-midscene-automation
1337
1327
  - 新增节点备注、未保存提醒和页面状态持久化。
1338
1328
 
1339
1329
  ## v0.1.13
1340
-
1341
1330
  - 修复历史脚本新增节点后无法覆盖保存的问题。
1342
1331
  - 修复连接脚本未按顺序继续执行的问题。
1343
1332
 
1344
1333
  ## v0.1.12
1345
-
1346
1334
  - “启动 APP”统一为流程开始节点操作,每个脚本最多添加一次。
1347
1335
  - 连接脚本回放时自动跳过子脚本的重复启动操作。
1348
1336
 
1349
1337
  ## v0.1.11
1350
-
1351
1338
  - 修复 npm 页面中的更新记录访问链接。
1352
1339
 
1353
1340
  ## v0.1.10
1354
-
1355
1341
  - npm 包新增项目更新记录,并清理未随包发布的内部文档链接。
1356
1342
 
1357
1343
  ## v0.1.9
1358
-
1359
1344
  - 录制步骤升级为可拖动、缩放和编辑的流程图,支持“是 / 否”判断分支。
1360
1345
  - 支持在流程任意位置插入操作,并编辑节点、定位器、超时和输入内容。
1361
1346
  - 支持 Activity 跳转检测和单 Activity 脚本录制保护。
@@ -1363,23 +1348,19 @@ Linux: ~/.local/share/android-midscene-automation
1363
1348
  - 新增脚本管理列表,可加载、删除和回放已录制脚本。
1364
1349
 
1365
1350
  ## v0.1.8
1366
-
1367
1351
  - 新增 selector 唯一性检测和父级上下文定位,解决多个组件共用同一 ID 的问题。
1368
1352
  - 回放支持多级定位和坐标兜底,并记录页面检查点与完整失败诊断信息。
1369
1353
 
1370
1354
  ## v0.1.7
1371
-
1372
1355
  - 新增存在则点击、输入、清空、返回等可选操作。
1373
1356
  - 新增等待出现、判断存在和等待消失等等待与判断能力。
1374
1357
 
1375
1358
  ## v0.1.6
1376
-
1377
1359
  - 新增可复用设备预览,支持点击画面选择组件树节点和刷新实时画面。
1378
1360
  - 组件树支持滚动浏览并展示当前 Activity。
1379
1361
  - 录制操作统一通过“添加操作”菜单选择。
1380
1362
 
1381
1363
  ## v0.1.5
1382
-
1383
1364
  - 首次提供 Appium 无模型录制与回放能力。
1384
1365
  - 支持从组件树录制点击、输入、断言和延时,并保存为可回放脚本。
1385
1366
  - App 包名与参数配置中的预设 App 统一管理。
package/index.html CHANGED
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Midscene 脚本后台</title>
6
+ <title>移动自动化控制台</title>
7
7
  </head>
8
8
  <body>
9
9
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
@@ -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.0.tgz",
42
- "@midscene/android-playground": "1.12.0",
43
- "@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.0.tgz",
44
- "@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.0.tgz",
45
- "@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.0.tgz",
46
- "@midscene/web": "1.12.0",
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",
47
47
  "@vitejs/plugin-vue": "6.0.7",
48
48
  "@vue-flow/core": "1.48.2",
49
49
  "archiver": "7.0.1",
@@ -1,4 +1,6 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { BoundedLoopTraversal } from './bounded-loop';
3
+ import { validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
2
4
  import { adbScreenshotBase64 } from './screenshot';
3
5
  import { recognizeDeviceScreen } from './ai-recognition';
4
6
  import { formatStageLog } from '../../src/appium-recorder/stage-log';
@@ -916,6 +918,15 @@ async function readNativeControlState(sessionId: string, step: AppiumRecordedSte
916
918
  }
917
919
 
918
920
  async function evaluateCondition(sessionId: string, deviceId: string, step: AppiumRecordedStepRecord) {
921
+ if (step.type === 'loop') {
922
+ if (step.loop?.exitWhen === 'never') return false;
923
+ const selector = toAppiumUsing(step.selector!);
924
+ const payload = await appiumRequest<AppiumValueResponse<Record<string, string>[]>>(`/session/${sessionId}/elements`, {
925
+ method: 'POST', body: JSON.stringify(selector),
926
+ });
927
+ if (!Array.isArray(payload.value)) throw new Error('循环退出条件查询返回了无效结果');
928
+ return step.loop?.exitWhen === 'exists' ? payload.value.length > 0 : payload.value.length === 0;
929
+ }
919
930
  if (step.type === 'textClick') {
920
931
  const selector = textClickSelector(step);
921
932
  const deadline = Date.now() + Math.max(0, step.timeoutMs ?? 10000);
@@ -1121,35 +1132,18 @@ async function replayFlowSteps(
1121
1132
  options: ReplayStepOptions = {},
1122
1133
  ) {
1123
1134
  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
- };
1135
+ const traversal = new BoundedLoopTraversal(steps);
1136
+ const nextIndexAfterStep = (index: number, step: AppiumRecordedStepRecord) => traversal.next(step, index);
1141
1137
  const visitedPath: string[] = [];
1142
1138
  let index: number | undefined = 0;
1143
- let guard = 0;
1144
- const maxVisits = Math.max(steps.length * 4, 20);
1145
1139
 
1146
1140
  while (typeof index === 'number' && index >= 0 && index < steps.length) {
1147
1141
  if (replayContext.getStore()?.flowEnded) return;
1148
1142
  throwIfReplayStopped();
1149
- guard += 1;
1150
- if (guard > maxVisits) throw new Error('流程图可能存在循环,已终止回放');
1151
-
1152
1143
  const step = steps[index];
1144
+ traversal.visit(step);
1145
+ const loopContext = traversal.context(step);
1146
+ const frameStep = loopContext ? { ...step, label: `${step.label}(${loopContext})` } : step;
1153
1147
  if (shouldSkipLinkedScriptInitStep(options, step)) {
1154
1148
  lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
1155
1149
  index = nextIndexAfterStep(index, step);
@@ -1157,11 +1151,28 @@ async function replayFlowSteps(
1157
1151
  }
1158
1152
  visitedPath.push(step.label);
1159
1153
  const nodeKind = defaultFlowKind(step);
1160
- lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
1161
- await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'before', '执行前');
1154
+ lines.push(`[节点 ${index + 1}] 开始:${frameStep.label}`);
1155
+ await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'before', '执行前');
1162
1156
 
1163
1157
  if (nodeKind === 'condition') {
1164
1158
  try {
1159
+ if (step.type === 'loop') {
1160
+ const iterations = traversal.iteration(step);
1161
+ const atLimit = iterations >= step.loop!.maxIterations;
1162
+ const exitMatched = !atLimit && await evaluateCondition(sessionId, deviceId, step);
1163
+ if (atLimit || exitMatched) {
1164
+ const reason = atLimit ? '达到最大次数' : '满足退出条件';
1165
+ lines.push(`[循环 ${step.label}] 结束:${reason},已执行 ${iterations} 轮`);
1166
+ await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', `${reason},已执行 ${iterations} 轮`);
1167
+ index = traversal.exit(step);
1168
+ } else {
1169
+ const entered = traversal.enter(step);
1170
+ lines.push(`[循环 ${step.label}] 第 ${entered.iteration}/${step.loop!.maxIterations} 轮开始`);
1171
+ await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', `第 ${entered.iteration}/${step.loop!.maxIterations} 轮`);
1172
+ index = entered.next;
1173
+ }
1174
+ continue;
1175
+ }
1165
1176
  // AI 节点使用本次执行前截图,并将同一张图片和识别依据写入结果帧。
1166
1177
  const beforeFrame = replayContext.getStore()?.frames.at(-1);
1167
1178
  const recognition = step.type === 'aiRecognition' ? await recognizeDeviceScreen({
@@ -1172,19 +1183,20 @@ async function replayFlowSteps(
1172
1183
  const matched = recognition ? recognition.result : await evaluateCondition(sessionId, deviceId, step);
1173
1184
  if (recognition) lines.push(`[节点 ${index + 1}] AI 识别:${recognition.result},耗时 ${recognition.durationMs}ms${recognition.reason ? `,依据:${recognition.reason}` : ''}`);
1174
1185
  const branchTargetId = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
1175
- const targetId = branchTargetId || step.flow?.successTargetId || '';
1186
+ const next = traversal.branch(step, matched);
1187
+ const targetId = branchTargetId || (next === undefined ? '' : steps[next]?.id) || '';
1176
1188
  const branchLabel = flowBranchLabel(step, matched ? 'yes' : 'no');
1177
1189
  lines.push(`[节点 ${index + 1}] 判断:${branchLabel}${isNativeStateCondition(step) ? `(checked=${matched})` : ''}${targetId ? `,进入 ${targetId}` : ',流程结束'}`);
1178
1190
  await captureReplayFrame(
1179
1191
  sessionId,
1180
- step,
1192
+ frameStep,
1181
1193
  index + 1,
1182
1194
  options.scriptName || '',
1183
1195
  'after',
1184
1196
  `判断:${branchLabel}${recognition?.reason ? `;${recognition.reason}` : ''}`,
1185
1197
  recognition?.imageBase64,
1186
1198
  );
1187
- index = targetId ? idToIndex.get(targetId) : undefined;
1199
+ index = next;
1188
1200
  } catch (error) {
1189
1201
  if (isReplayStopped(error)) {
1190
1202
  lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
@@ -1201,7 +1213,13 @@ async function replayFlowSteps(
1201
1213
 
1202
1214
  try {
1203
1215
  let softFailed = false;
1204
- if (step.type === 'runScript') {
1216
+ if (step.type === 'breakLoop') {
1217
+ const exited = traversal.break(step);
1218
+ lines.push(`[循环 ${exited.loop.label}] 第 ${exited.iteration} 轮主动退出`);
1219
+ await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', `已退出循环:${exited.loop.label}`);
1220
+ index = exited.next;
1221
+ continue;
1222
+ } else if (step.type === 'runScript') {
1205
1223
  await replayLinkedScript(sessionId, deviceId, step, lines, stack);
1206
1224
  } else {
1207
1225
  const result = await runStep(sessionId, deviceId, step, {
@@ -1211,7 +1229,7 @@ async function replayFlowSteps(
1211
1229
  softFailed = appendRunStepResult(lines, index + 1, result);
1212
1230
  }
1213
1231
  lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
1214
- await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
1232
+ await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
1215
1233
  index = nextIndexAfterStep(index, step);
1216
1234
  } catch (error) {
1217
1235
  if (isReplayStopped(error)) {
@@ -1259,6 +1277,7 @@ async function replayScriptSteps(
1259
1277
  stack: string[],
1260
1278
  options: ReplayStepOptions = {},
1261
1279
  ) {
1280
+ validateLoopSteps(steps);
1262
1281
  let trailingLinkIndex = steps.length;
1263
1282
  while (
1264
1283
  trailingLinkIndex > 0
@@ -1334,6 +1353,7 @@ export async function replayAppiumScript(
1334
1353
  let stopped = false;
1335
1354
  let managedAppium: Awaited<ReturnType<typeof startManagedAppiumServer>> | null = null;
1336
1355
  try {
1356
+ validateLoopSteps(script.steps);
1337
1357
  lines.push('正在检测 Android SDK...');
1338
1358
  const androidSdk = ensureAndroidSdkAvailable();
1339
1359
  lines.push(`Android SDK 已就绪:${androidSdk.root}`);
@@ -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
+ }