android-midscene-automation 0.1.6 → 0.1.9
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/README.md +8 -10
- package/package.json +3 -3
- package/server/appium-recorder/appium-runner.ts +362 -26
- package/server/appium-recorder/repository.ts +89 -12
- package/src/appium-recorder/AppiumPage.vue +504 -124
- package/src/appium-recorder/components/ComponentTree.vue +14 -1
- package/src/appium-recorder/components/NodeDetail.vue +24 -118
- package/src/appium-recorder/components/RecordedSteps.vue +898 -36
- package/src/appium-recorder/tree.ts +55 -9
- package/src/appium-recorder/types.ts +33 -1
- package/src/style.css +659 -11
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Midscene
|
|
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
|
|
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,17 @@ 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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
134
|
+
## 功能文档
|
|
135
|
+
|
|
136
|
+
- [Appium Recorder 实现说明](docs/appium-recorder/implementation-notes.md)
|
|
137
|
+
- [Appium 录制稳定性增强方案](docs/appium-recorder/robust-recording-design.md)
|
|
138
|
+
- [项目更新记录](docs/appium-recorder/changelog.md)
|
|
141
139
|
|
|
142
140
|
## 运行 E2E 测试
|
|
143
141
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "android-midscene-automation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"android-midscene-automation": "./bin/android-midscene-automation.js"
|
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
"README.md"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
-
"dev": "vite --host
|
|
19
|
+
"dev": "vite --host 127.0.0.1",
|
|
20
20
|
"remote-agent": "tsx remote-agent/index.ts",
|
|
21
21
|
"build": "vue-tsc -b && vite build",
|
|
22
|
-
"preview": "vite preview --host
|
|
22
|
+
"preview": "vite preview --host 127.0.0.1",
|
|
23
23
|
"pw:install": "playwright install chromium",
|
|
24
24
|
"test:e2e": "playwright test"
|
|
25
25
|
},
|
|
@@ -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
|
|
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(
|
|
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(
|
|
222
|
+
throw new Error('需要使用 bounds 坐标执行');
|
|
191
223
|
}
|
|
192
224
|
|
|
193
|
-
async function
|
|
194
|
-
|
|
195
|
-
|
|
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(
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
750
|
+
const detail = errorDetail(error);
|
|
751
|
+
if (detail.includes('Appium Settings app is not running')) {
|
|
419
752
|
await appendSettingsDiagnostics(lines, targetDeviceId);
|
|
420
753
|
}
|
|
421
|
-
|
|
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) {
|