android-midscene-automation 0.1.6 → 0.1.10

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 ADDED
@@ -0,0 +1,82 @@
1
+ # 更新记录
2
+
3
+ ## v0.1.10
4
+
5
+ - npm 包新增根目录 `CHANGELOG.md`,README 可直接查看项目更新记录。
6
+ - README 删除未随 npm 包发布的内部文档链接。
7
+
8
+ ## v0.1.9
9
+
10
+ - Appium 模块:录制流程新增前置操作和后置操作区域,分别固定在“开始”之前和“结束”之后,目前仅支持“启动 APP”和“添加延时”。
11
+ - Appium 模块:“启动 APP”回放时通过 ADB 启动当前预设 App 参数对应的应用包。
12
+ - Appium 模块:节点详情的 selector 展示新增当前 Activity。
13
+ - 文档:新增同组件不同内容弹窗的判断操作说明。
14
+ - Appium 模块:“判断弹窗”改名为“判断存在”,支持用于弹窗以外的任意组件判断。
15
+ - Appium 模块:判断节点的“是 / 否”分支支持直接插入操作,并自动连接到新步骤。
16
+ - Appium 模块:判断节点分支内插入的步骤会按“是 / 否”归类展示,不再混入主线单列。
17
+ - Appium 模块:判断节点后的步骤改为沿“是 / 否”分支线向下延伸,不再嵌套在分支容器中。
18
+ - Appium 模块:统一分支步骤节点宽度与居中对齐,并补充删除节点操作。
19
+ - Appium 模块:检测到 Activity 跳转时强制保存当前脚本,每份录制脚本限制在单个 Activity 内。
20
+ - Appium 模块:录制步骤画布支持按住 Ctrl 使用鼠标滚轮缩放,缩放范围为 50%–200%。
21
+ - Appium 模块:修复可拖动画布吞掉分支节点删除点击的问题,并在删除后自动修正流程目标引用。
22
+ - Appium 模块:操作入口移动到录制流程的“开始”节点下方;分支仅关联从“是/否”入口插入的操作,并统一分支节点卡片与间距。
23
+ - Appium 模块:加载已保存脚本时校验绑定 Activity;当前页面不一致时锁定流程编辑,避免跨 Activity 继续追加录制步骤。
24
+ - Appium 模块:步骤操作新增“系统返回”(Android keyCode 4);Activity 不匹配时仅允许将该操作插入脚本,其他流程编辑继续锁定。
25
+ - Appium 模块:连接脚本按插入点 Activity 过滤并校验目标脚本入口 Activity;回放时再次校验手机当前 Activity,阻止尚未跳转就执行其他页面脚本。
26
+ - Appium 模块:普通流程步骤与展开编辑面板改为紧凑宽度,减少可拖动画布中的横向空白。
27
+ - Appium 模块:修复可拖动画布捕获步骤卡片点击的问题,步骤详情现在可通过再次点击卡片正常收起。
28
+ - Appium 模块:判断节点分支支持连续追加多个操作。
29
+ - Appium 模块:录制步骤区域改为可拖拽流程画布,默认显示第一个节点视角。
30
+ - Appium 模块:录制步骤区域新增“放大”按钮,可弹出流程总览查看全部节点。
31
+ - Appium 模块:判断分支线条改为连续曲线样式,减少断线和空白间隔。
32
+ - Appium 模块:判断节点展示改为左右分叉流程样式。
33
+ - Appium 模块:支持在任意录制步骤后插入其他操作。
34
+ - Appium 模块:录制步骤改为流程图节点样式。
35
+ - Appium 模块:支持点击步骤节点展开编辑面板。
36
+ - Appium 模块:支持修改节点名称、节点类型、超时时间、输入内容和可选状态。
37
+ - Appium 模块:支持编辑 selector 和父级上下文 selector。
38
+ - Appium 模块:支持判断节点配置“是 / 否”分支目标。
39
+ - Appium 模块:检测到页面变化时支持留在当前页面、保存为新用例或添加返回键继续录制。
40
+ - Appium 模块:支持连接已保存脚本,用于登录脚本接首页脚本等组合场景。
41
+ - Appium 模块:新增脚本列表 Tab,支持加载和删除已录制脚本。
42
+ - Appium 模块:回放输出支持展开查看和清除日志。
43
+ - Appium 模块:脚本存储移除旧版 `steps_json`,只保留 `flow_json`。
44
+
45
+ ## v0.1.8
46
+
47
+ - Appium 模块:组件树新增 selector 唯一性检测。
48
+ - Appium 模块:组件树节点展示“唯一”或“重复 N”状态。
49
+ - Appium 模块:重复 selector 自动记录父级上下文定位信息。
50
+ - Appium 模块:回放时支持“父级上下文 + 子级 selector”定位。
51
+ - Appium 模块:回放查找顺序调整为父级上下文、备用 selector、当前 selector、坐标兜底。
52
+ - Appium 模块:每个录制步骤新增页面检查点,保存录制前后 Activity 和页面摘要。
53
+ - Appium 模块:回放失败日志新增当前 Activity、selector、上下文 selector、备用 selector 和录制前后 Activity。
54
+
55
+ ## v0.1.7
56
+
57
+ - Appium 模块:新增“存在则点击”操作。
58
+ - Appium 模块:新增“存在则输入”操作。
59
+ - Appium 模块:新增“存在则清空”操作。
60
+ - Appium 模块:新增“存在则返回”操作。
61
+ - Appium 模块:新增“等待出现”操作。
62
+ - Appium 模块:新增“判断存在”操作。
63
+ - Appium 模块:新增“等待元素消失”操作。
64
+ - Appium 模块:“存在则...”类操作找不到目标时不再中断回放,并在日志中显示“未出现,已跳过”。
65
+
66
+ ## v0.1.6
67
+
68
+ - Appium 模块:新增设备预览复用组件。
69
+ - Appium 模块:设备预览支持点击画面选择组件树节点。
70
+ - Appium 模块:设备预览支持刷新当前画面。
71
+ - Appium 模块:App 组件树区域支持滚动浏览。
72
+ - Appium 模块:App 组件树下方展示当前 Activity。
73
+ - Appium 模块:操作按钮统一为“添加操作”下拉列表。
74
+ - Appium 模块:删除截图操作入口。
75
+
76
+ ## v0.1.5
77
+
78
+ - Appium 模块:新增组件树录制页面。
79
+ - Appium 模块:支持从组件树选择节点并录制点击、输入、断言、延时。
80
+ - Appium 模块:支持保存 Appium 录制脚本。
81
+ - Appium 模块:支持通过 Appium 回放录制脚本。
82
+ - Appium 模块:App 包名只能从“预设 App 参数”中选择。
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Midscene Playwright Admin
1
+ # Android Midscene Automation
2
2
 
3
3
  一个最小可用的 Midscene + Playwright 项目,同时提供一个基于 Vue 3 和 Element Plus 的后台,用来把自然语言 prompt 生成结构化自动化脚本。
4
4
 
@@ -48,17 +48,16 @@ npm run dev
48
48
  PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright/ npx playwright install chromium
49
49
  ```
50
50
 
51
- ## 局域网访问
51
+ ## 本地启动
52
52
 
53
53
  ```sh
54
54
  npm run dev
55
55
  ```
56
56
 
57
- 启动后访问 Vite 输出的地址。项目默认监听 `0.0.0.0`,支持局域网访问:
57
+ 启动后访问 Vite 输出的本机地址。项目默认只监听 `127.0.0.1`:
58
58
 
59
59
  ```text
60
60
  http://127.0.0.1:5173/
61
- http://你的局域网IP:5173/
62
61
  ```
63
62
 
64
63
  `npm run dev` 会同时启动前端页面和本地后端 API。后端不是独立进程,而是在 `vite.config.ts` 中通过 `server/http-api.ts` 挂载到 Vite dev server 的 Connect middleware。
@@ -126,18 +125,15 @@ http://localhost:5173/
126
125
 
127
126
  这样后端执行的是当前电脑上的 `adb devices`,页面会识别当前电脑连接的手机。
128
127
 
129
- 局域网共享页面只适合查看同一个中央服务,不适合直接识别访问者电脑上的 ADB 设备。远程设备代理方案仅保留在源码仓库的 `demo` 目录中,不随 npm 包发布。
130
-
131
128
  如果要回放 Appium 脚本,本机还需要启动 Appium:
132
129
 
133
130
  ```sh
134
131
  appium
135
132
  ```
136
- 如果要使用远程设备代理实验方案,可以在访问者电脑的项目目录中运行:
137
133
 
138
- ```sh
139
- npm run remote-agent -- --server http://中央服务IP:5173 --agent-id 你的名字或机器名
140
- ```
134
+ ## 功能文档
135
+
136
+ - [项目更新记录](CHANGELOG.md)
141
137
 
142
138
  ## 运行 E2E 测试
143
139
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.1.6",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "android-midscene-automation": "./bin/android-midscene-automation.js"
@@ -13,13 +13,14 @@
13
13
  "src",
14
14
  "tsconfig*.json",
15
15
  "vite.config.ts",
16
- "README.md"
16
+ "README.md",
17
+ "CHANGELOG.md"
17
18
  ],
18
19
  "scripts": {
19
- "dev": "vite --host 0.0.0.0",
20
+ "dev": "vite --host 127.0.0.1",
20
21
  "remote-agent": "tsx remote-agent/index.ts",
21
22
  "build": "vue-tsc -b && vite build",
22
- "preview": "vite preview --host 0.0.0.0",
23
+ "preview": "vite preview --host 127.0.0.1",
23
24
  "pw:install": "playwright install chromium",
24
25
  "test:e2e": "playwright test"
25
26
  },
@@ -1,7 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import type { AppiumRecordedScriptRecord, AppiumRecordedStepRecord } from './repository';
4
+ import { getAppiumRecordedScript, type AppiumRecordedScriptRecord, type AppiumRecordedStepRecord } from './repository';
5
5
  import { appDataPath } from '../paths';
6
6
 
7
7
  type AppiumSessionResponse = {
@@ -53,6 +53,30 @@ function adbTap(deviceId: string, x: number, y: number) {
53
53
  });
54
54
  }
55
55
 
56
+ function adbLaunchApp(deviceId: string, packageName: string) {
57
+ if (!packageName) return Promise.reject(new Error('启动 APP 缺少包名'));
58
+ return new Promise<void>((resolve, reject) => {
59
+ execFile('adb', [
60
+ '-s',
61
+ deviceId,
62
+ 'shell',
63
+ 'monkey',
64
+ '-p',
65
+ packageName,
66
+ '-c',
67
+ 'android.intent.category.LAUNCHER',
68
+ '1',
69
+ ], { maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
70
+ const output = `${stdout || ''}\n${stderr || ''}`.trim();
71
+ if (error || /no activities found|monkey aborted/i.test(output)) {
72
+ reject(new Error(output || error?.message || `ADB 启动 ${packageName} 失败`));
73
+ return;
74
+ }
75
+ resolve();
76
+ });
77
+ });
78
+ }
79
+
56
80
  function adbSwipe(deviceId: string, step: Required<AppiumRecordedStepRecord>['swipe']) {
57
81
  return new Promise<void>((resolve, reject) => {
58
82
  execFile('adb', [
@@ -117,6 +141,16 @@ async function appendSettingsDiagnostics(lines: string[], deviceId: string) {
117
141
  lines.push(`- 服务状态:${serviceInfo || '-'}`);
118
142
  }
119
143
 
144
+ function appendAndroidSdkDiagnostics(lines: string[]) {
145
+ lines.push('Android SDK 环境变量未配置:');
146
+ lines.push('- Windows 常见 SDK 路径:%LOCALAPPDATA%\\Android\\Sdk');
147
+ lines.push('- PowerShell 设置示例:');
148
+ lines.push(' [Environment]::SetEnvironmentVariable("ANDROID_HOME", "$env:LOCALAPPDATA\\Android\\Sdk", "User")');
149
+ lines.push(' [Environment]::SetEnvironmentVariable("ANDROID_SDK_ROOT", "$env:LOCALAPPDATA\\Android\\Sdk", "User")');
150
+ lines.push(' [Environment]::SetEnvironmentVariable("Path", $env:Path + ";$env:LOCALAPPDATA\\Android\\Sdk\\platform-tools", "User")');
151
+ lines.push('- 设置后请重新打开终端,并重启 appium 服务。');
152
+ }
153
+
120
154
  async function appiumRequest<T>(path: string, init?: RequestInit) {
121
155
  const requestUrl = `${appiumServerUrl()}${path}`;
122
156
  let response: Response;
@@ -180,27 +214,63 @@ async function createSession(script: AppiumRecordedScriptRecord, deviceId: strin
180
214
  return sessionId;
181
215
  }
182
216
 
183
- function toAppiumUsing(step: AppiumRecordedStepRecord) {
184
- const selector = step.selector;
185
- if (!selector) throw new Error(`${step.label} 缺少 selector`);
217
+ function toAppiumUsing(selector: NonNullable<AppiumRecordedStepRecord['selector']>) {
186
218
  if (selector.strategy === 'accessibilityId') return { using: 'accessibility id', value: selector.value || '' };
187
219
  if (selector.strategy === 'id') return { using: 'id', value: selector.value || '' };
188
220
  if (selector.strategy === 'androidUiAutomator') return { using: '-android uiautomator', value: selector.value || '' };
189
221
  if (selector.strategy === 'xpath') return { using: 'xpath', value: selector.value || '' };
190
- throw new Error(`${step.label} 需要使用 bounds 坐标执行`);
222
+ throw new Error('需要使用 bounds 坐标执行');
191
223
  }
192
224
 
193
- async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
194
- const using = toAppiumUsing(step);
195
- const payload = await appiumRequest<AppiumElementResponse>(`/session/${sessionId}/element`, {
225
+ async function findElementBySelector(
226
+ sessionId: string,
227
+ selector: NonNullable<AppiumRecordedStepRecord['selector']>,
228
+ parentElementId?: string,
229
+ ) {
230
+ const using = toAppiumUsing(selector);
231
+ const path = parentElementId
232
+ ? `/session/${sessionId}/element/${parentElementId}/element`
233
+ : `/session/${sessionId}/element`;
234
+ const payload = await appiumRequest<AppiumElementResponse>(path, {
196
235
  method: 'POST',
197
236
  body: JSON.stringify(using),
198
237
  });
199
238
  const elementId = payload.value?.[ELEMENT_KEY] || payload.value?.ELEMENT || '';
200
- if (!elementId) throw new Error(`${step.label} 未找到元素`);
239
+ if (!elementId) throw new Error('未找到元素');
201
240
  return elementId;
202
241
  }
203
242
 
243
+ async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
244
+ if (!step.selector) throw new Error(`${step.label} 缺少 selector`);
245
+ const attempts: string[] = [];
246
+
247
+ if (step.contextSelector) {
248
+ try {
249
+ attempts.push(`context ${step.contextSelector.strategy} ${step.contextSelector.value || ''} -> ${step.selector.strategy} ${step.selector.value || ''}`);
250
+ const parentId = await findElementBySelector(sessionId, step.contextSelector);
251
+ return await findElementBySelector(sessionId, step.selector, parentId);
252
+ } catch {
253
+ // Continue with the fallback chain below.
254
+ }
255
+ }
256
+
257
+ for (const selector of step.selectorChain || []) {
258
+ try {
259
+ attempts.push(`${selector.strategy} ${selector.value || ''}`);
260
+ return await findElementBySelector(sessionId, selector);
261
+ } catch {
262
+ // Continue.
263
+ }
264
+ }
265
+
266
+ try {
267
+ attempts.push(`${step.selector.strategy} ${step.selector.value || ''}`);
268
+ return await findElementBySelector(sessionId, step.selector);
269
+ } catch (error) {
270
+ throw new Error(`${step.label} 未找到元素;尝试过:${attempts.filter(Boolean).join(';') || '-'};${errorDetail(error)}`);
271
+ }
272
+ }
273
+
204
274
  async function tapFallback(deviceId: string, step: AppiumRecordedStepRecord) {
205
275
  if (step.fallback?.strategy !== 'bounds' || !Number.isFinite(step.fallback.centerX) || !Number.isFinite(step.fallback.centerY)) {
206
276
  throw new Error(`${step.label} 未找到元素,且没有可用坐标兜底`);
@@ -224,6 +294,19 @@ async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord)
224
294
  throw lastError instanceof Error ? lastError : new Error(`${step.label} 等待超时`);
225
295
  }
226
296
 
297
+ async function findOptionalElement(sessionId: string, step: AppiumRecordedStepRecord) {
298
+ const startedAt = Date.now();
299
+ const timeoutMs = step.timeoutMs ?? 2000;
300
+ while (Date.now() - startedAt <= timeoutMs) {
301
+ try {
302
+ return await findElement(sessionId, step);
303
+ } catch {
304
+ await wait(250);
305
+ }
306
+ }
307
+ return '';
308
+ }
309
+
227
310
  async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRecord) {
228
311
  const startedAt = Date.now();
229
312
  const timeoutMs = step.timeoutMs || 10000;
@@ -267,11 +350,9 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
267
350
  }
268
351
 
269
352
  if (step.type === 'launchApp') {
270
- await appiumRequest(`/session/${sessionId}/appium/device/activate_app`, {
271
- method: 'POST',
272
- body: JSON.stringify({ appId: step.value }),
273
- });
274
- return;
353
+ const packageName = step.value || '';
354
+ await adbLaunchApp(deviceId, packageName);
355
+ return `ADB 已启动 ${packageName}`;
275
356
  }
276
357
 
277
358
  if (step.type === 'screenshot') {
@@ -324,6 +405,46 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
324
405
  return;
325
406
  }
326
407
 
408
+ if (step.type === 'tapIfExists') {
409
+ const elementId = await findOptionalElement(sessionId, step);
410
+ if (!elementId) return '未出现,已跳过';
411
+ await appiumRequest(`/session/${sessionId}/element/${elementId}/click`, {
412
+ method: 'POST',
413
+ body: JSON.stringify({}),
414
+ });
415
+ return '已出现,已点击';
416
+ }
417
+
418
+ if (step.type === 'inputIfExists') {
419
+ const elementId = await findOptionalElement(sessionId, step);
420
+ if (!elementId) return '未出现,已跳过';
421
+ await appiumRequest(`/session/${sessionId}/element/${elementId}/value`, {
422
+ method: 'POST',
423
+ body: JSON.stringify({ text: step.value || '', value: [...(step.value || '')] }),
424
+ });
425
+ return '已出现,已输入';
426
+ }
427
+
428
+ if (step.type === 'clearIfExists') {
429
+ const elementId = await findOptionalElement(sessionId, step);
430
+ if (!elementId) return '未出现,已跳过';
431
+ await appiumRequest(`/session/${sessionId}/element/${elementId}/clear`, {
432
+ method: 'POST',
433
+ body: JSON.stringify({}),
434
+ });
435
+ return '已出现,已清空';
436
+ }
437
+
438
+ if (step.type === 'backIfExists') {
439
+ const elementId = await findOptionalElement(sessionId, step);
440
+ if (!elementId) return '未出现,已跳过';
441
+ await appiumRequest(`/session/${sessionId}/appium/device/press_keycode`, {
442
+ method: 'POST',
443
+ body: JSON.stringify({ keycode: 4 }),
444
+ });
445
+ return '已出现,已返回';
446
+ }
447
+
327
448
  if (step.type === 'waitFor' || step.type === 'assertExists') {
328
449
  await waitForElement(sessionId, step);
329
450
  return;
@@ -386,6 +507,225 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
386
507
  }
387
508
  }
388
509
 
510
+ async function evaluateCondition(sessionId: string, deviceId: string, step: AppiumRecordedStepRecord) {
511
+ try {
512
+ if (step.type === 'waitActivity') {
513
+ await waitForActivity(deviceId, step);
514
+ return true;
515
+ }
516
+ if (step.type === 'waitDisappear') {
517
+ await waitForElementGone(sessionId, step);
518
+ return true;
519
+ }
520
+ if (step.type === 'assertText') {
521
+ const elementId = await findElement(sessionId, step);
522
+ const payload = await appiumRequest<AppiumValueResponse<string>>(`/session/${sessionId}/element/${elementId}/text`);
523
+ return (payload.value || '').includes(step.value || '');
524
+ }
525
+ await waitForElement(sessionId, step);
526
+ return true;
527
+ } catch {
528
+ return false;
529
+ }
530
+ }
531
+
532
+ function hasFlowSteps(steps: AppiumRecordedStepRecord[]) {
533
+ return steps.some((step) => (
534
+ step.flow?.nodeKind === 'condition'
535
+ || Boolean(step.flow?.yesTargetId)
536
+ || Boolean(step.flow?.noTargetId)
537
+ || Boolean(step.flow?.successTargetId)
538
+ || Boolean(step.flow?.failureTargetId)
539
+ ));
540
+ }
541
+
542
+ async function appendStepDiagnostics(lines: string[], deviceId: string, step: AppiumRecordedStepRecord) {
543
+ const currentActivity = await getCurrentActivity(deviceId).catch(() => '');
544
+ lines.push(`诊断 Activity:${currentActivity || '-'}`);
545
+ lines.push(`诊断 selector:${step.selector ? `${step.selector.strategy} ${step.selector.value || ''}` : '-'}`);
546
+ if (step.contextSelector) {
547
+ lines.push(`诊断上下文:${step.contextSelector.strategy} ${step.contextSelector.value || ''}`);
548
+ }
549
+ if (step.selectorChain?.length) {
550
+ lines.push(`诊断备用 selector:${step.selectorChain.map((selector) => `${selector.strategy} ${selector.value || ''}`).join(';')}`);
551
+ }
552
+ if (step.pageBefore) {
553
+ lines.push(`录制前 Activity:${step.pageBefore.activity || '-'}`);
554
+ }
555
+ if (step.pageAfter) {
556
+ lines.push(`录制后 Activity:${step.pageAfter.activity || '-'}`);
557
+ }
558
+ }
559
+
560
+ async function replayLinkedScript(
561
+ sessionId: string,
562
+ deviceId: string,
563
+ step: AppiumRecordedStepRecord,
564
+ lines: string[],
565
+ stack: string[],
566
+ ) {
567
+ const scriptId = step.value || '';
568
+ if (!scriptId) throw new Error(`${step.label} 缺少连接脚本 ID`);
569
+ if (stack.includes(scriptId)) throw new Error(`${step.label} 检测到循环连接脚本`);
570
+ const linkedScript = getAppiumRecordedScript(scriptId);
571
+ if (!linkedScript) throw new Error(`${step.label} 指向的脚本不存在`);
572
+
573
+ const currentActivity = await getCurrentActivity(deviceId).catch(() => '');
574
+ if (linkedScript.appActivity && currentActivity !== linkedScript.appActivity) {
575
+ throw new Error(
576
+ `${step.label} 无法执行:当前 Activity 为 ${currentActivity || '-'},`
577
+ + `目标脚本入口 Activity 为 ${linkedScript.appActivity}`,
578
+ );
579
+ }
580
+
581
+ lines.push(`连接脚本开始:${linkedScript.name}`);
582
+ const nextStack = [...stack, linkedScript.id];
583
+ await replayScriptSteps(sessionId, deviceId, linkedScript.steps, lines, nextStack);
584
+ lines.push(`连接脚本完成:${linkedScript.name}`);
585
+ }
586
+
587
+ async function replayLinearSteps(
588
+ sessionId: string,
589
+ deviceId: string,
590
+ steps: AppiumRecordedStepRecord[],
591
+ lines: string[],
592
+ stack: string[] = [],
593
+ ) {
594
+ for (const [index, step] of steps.entries()) {
595
+ lines.push(`[步骤 ${index + 1}] 开始:${step.label}`);
596
+ try {
597
+ if (step.type === 'runScript') {
598
+ await replayLinkedScript(sessionId, deviceId, step, lines, stack);
599
+ } else {
600
+ const result = await runStep(sessionId, deviceId, step);
601
+ if (result) lines.push(`[步骤 ${index + 1}] 结果:${result}`);
602
+ }
603
+ lines.push(`[步骤 ${index + 1}] 完成:${step.label}`);
604
+ } catch (error) {
605
+ if (step.optional) {
606
+ lines.push(`[步骤 ${index + 1}] 跳过:${errorDetail(error)}`);
607
+ continue;
608
+ }
609
+ lines.push(`[步骤 ${index + 1}] 失败:${errorDetail(error)}`);
610
+ await appendStepDiagnostics(lines, deviceId, step);
611
+ throw error;
612
+ }
613
+ }
614
+ }
615
+
616
+ async function replayFlowSteps(
617
+ sessionId: string,
618
+ deviceId: string,
619
+ steps: AppiumRecordedStepRecord[],
620
+ lines: string[],
621
+ stack: string[] = [],
622
+ ) {
623
+ const idToIndex = new Map(steps.map((step, index) => [step.id, index]));
624
+ const visitedPath: string[] = [];
625
+ let index: number | undefined = 0;
626
+ let guard = 0;
627
+ const maxVisits = Math.max(steps.length * 4, 20);
628
+
629
+ while (typeof index === 'number' && index >= 0 && index < steps.length) {
630
+ guard += 1;
631
+ if (guard > maxVisits) throw new Error('流程图可能存在循环,已终止回放');
632
+
633
+ const step = steps[index];
634
+ visitedPath.push(step.label);
635
+ const nodeKind = step.flow?.nodeKind || (step.type === 'assertExists' || step.type === 'assertText' ? 'assertion' : 'action');
636
+ lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
637
+
638
+ if (nodeKind === 'condition') {
639
+ const matched = await evaluateCondition(sessionId, deviceId, step);
640
+ const targetId = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
641
+ lines.push(`[节点 ${index + 1}] 判断:${matched ? '是' : '否'}${targetId ? `,进入 ${targetId}` : ',流程结束'}`);
642
+ index = targetId ? idToIndex.get(targetId) : (matched ? index + 1 : undefined);
643
+ continue;
644
+ }
645
+
646
+ try {
647
+ if (step.type === 'runScript') {
648
+ await replayLinkedScript(sessionId, deviceId, step, lines, stack);
649
+ } else {
650
+ const result = await runStep(sessionId, deviceId, step);
651
+ if (result) lines.push(`[节点 ${index + 1}] 结果:${result}`);
652
+ }
653
+ lines.push(`[节点 ${index + 1}] 完成:${step.label}`);
654
+ const targetId = step.flow?.successTargetId;
655
+ index = targetId ? idToIndex.get(targetId) : index + 1;
656
+ } catch (error) {
657
+ if (step.optional) {
658
+ lines.push(`[节点 ${index + 1}] 跳过:${errorDetail(error)}`);
659
+ index += 1;
660
+ continue;
661
+ }
662
+ const failureTarget = step.flow?.failureTargetId;
663
+ if (failureTarget && idToIndex.has(failureTarget)) {
664
+ lines.push(`[节点 ${index + 1}] 失败分支:${errorDetail(error)}`);
665
+ index = idToIndex.get(failureTarget);
666
+ continue;
667
+ }
668
+ lines.push(`[节点 ${index + 1}] 失败:${errorDetail(error)}`);
669
+ lines.push(`执行路径:${visitedPath.join(' -> ')}`);
670
+ await appendStepDiagnostics(lines, deviceId, step);
671
+ throw error;
672
+ }
673
+ }
674
+ }
675
+
676
+ async function replayStepGroup(
677
+ sessionId: string,
678
+ deviceId: string,
679
+ steps: AppiumRecordedStepRecord[],
680
+ lines: string[],
681
+ stack: string[],
682
+ ) {
683
+ if (!steps.length) return;
684
+ if (hasFlowSteps(steps)) {
685
+ await replayFlowSteps(sessionId, deviceId, steps, lines, stack);
686
+ } else {
687
+ await replayLinearSteps(sessionId, deviceId, steps, lines, stack);
688
+ }
689
+ }
690
+
691
+ async function replayScriptSteps(
692
+ sessionId: string,
693
+ deviceId: string,
694
+ steps: AppiumRecordedStepRecord[],
695
+ lines: string[],
696
+ stack: string[],
697
+ ) {
698
+ const hasScopedSteps = steps.some((step) => step.flow?.scope === 'pre' || step.flow?.scope === 'post');
699
+ if (!hasScopedSteps) {
700
+ await replayStepGroup(sessionId, deviceId, steps, lines, stack);
701
+ return;
702
+ }
703
+
704
+ const preSteps = steps.filter((step) => step.flow?.scope === 'pre');
705
+ const mainSteps = steps.filter((step) => !step.flow?.scope || step.flow.scope === 'main');
706
+ const postSteps = steps.filter((step) => step.flow?.scope === 'post');
707
+ let primaryError: unknown;
708
+
709
+ try {
710
+ if (preSteps.length) lines.push('执行前置操作...');
711
+ await replayStepGroup(sessionId, deviceId, preSteps, lines, stack);
712
+ if (mainSteps.length) lines.push('执行主流程...');
713
+ await replayStepGroup(sessionId, deviceId, mainSteps, lines, stack);
714
+ } catch (error) {
715
+ primaryError = error;
716
+ }
717
+
718
+ try {
719
+ if (postSteps.length) lines.push('执行后置操作...');
720
+ await replayStepGroup(sessionId, deviceId, postSteps, lines, stack);
721
+ } catch (error) {
722
+ if (!primaryError) throw error;
723
+ lines.push(`后置操作失败:${errorDetail(error)}`);
724
+ }
725
+
726
+ if (primaryError) throw primaryError;
727
+ }
728
+
389
729
  export async function replayAppiumScript(script: AppiumRecordedScriptRecord, deviceId: string) {
390
730
  const targetDeviceId = deviceId || script.deviceId;
391
731
  if (!targetDeviceId) throw new Error('未检测到可用设备');
@@ -402,23 +742,19 @@ export async function replayAppiumScript(script: AppiumRecordedScriptRecord, dev
402
742
  lines.push('正在创建 Appium session...');
403
743
  sessionId = await createSession(script, targetDeviceId);
404
744
  lines.push(`Appium session 已创建:${sessionId}`);
405
- for (const [index, step] of script.steps.entries()) {
406
- lines.push(`[步骤 ${index + 1}] 开始:${step.label}`);
407
- try {
408
- await runStep(sessionId, targetDeviceId, step);
409
- lines.push(`[步骤 ${index + 1}] 完成:${step.label}`);
410
- } catch (error) {
411
- lines.push(`[步骤 ${index + 1}] 失败:${errorDetail(error)}`);
412
- throw error;
413
- }
414
- }
745
+ if (hasFlowSteps(script.steps)) lines.push('按流程图路径回放...');
746
+ await replayScriptSteps(sessionId, targetDeviceId, script.steps, lines, [script.id]);
415
747
  lines.push('回放完成');
416
748
  return { success: true, output: lines.join('\n') };
417
749
  } catch (error) {
418
- if (errorDetail(error).includes('Appium Settings app is not running')) {
750
+ const detail = errorDetail(error);
751
+ if (detail.includes('Appium Settings app is not running')) {
419
752
  await appendSettingsDiagnostics(lines, targetDeviceId);
420
753
  }
421
- lines.push(`回放终止:${errorDetail(error)}`);
754
+ if (detail.includes('Neither ANDROID_HOME nor ANDROID_SDK_ROOT')) {
755
+ appendAndroidSdkDiagnostics(lines);
756
+ }
757
+ lines.push(`回放终止:${detail}`);
422
758
  return { success: false, output: lines.join('\n') };
423
759
  } finally {
424
760
  if (sessionId) {