android-midscene-automation 0.1.13 → 0.1.14
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 +30 -0
- package/README.md +39 -51
- package/USAGE.md +1018 -0
- package/package.json +2 -1
- package/server/android-sdk.ts +104 -0
- package/server/appium-recorder/appium-runner.ts +144 -35
- package/server/appium-recorder/report.ts +138 -0
- package/server/appium-recorder/repository.ts +78 -0
- package/server/appium-recorder/routes.ts +141 -14
- package/server/config.ts +12 -0
- package/server/http-api.ts +9 -6
- package/src/App.vue +23 -23
- package/src/appium-recorder/AppiumPage.vue +403 -38
- package/src/appium-recorder/api.ts +62 -5
- package/src/appium-recorder/components/FlowStepEditor.vue +175 -0
- package/src/appium-recorder/components/RecordedSteps.vue +514 -277
- package/src/appium-recorder/types.ts +2 -0
- package/src/pages/ConfigPage.vue +28 -0
- package/src/style.css +162 -89
- package/src/types.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "android-midscene-automation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"android-midscene-automation": "./bin/android-midscene-automation.js"
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"tsconfig*.json",
|
|
15
15
|
"vite.config.ts",
|
|
16
16
|
"README.md",
|
|
17
|
+
"USAGE.md",
|
|
17
18
|
"CHANGELOG.md"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { loadConfig } from './config';
|
|
5
|
+
import { appDataPath } from './paths';
|
|
6
|
+
|
|
7
|
+
export type AndroidSdkInfo = {
|
|
8
|
+
root: string;
|
|
9
|
+
adbPath: string;
|
|
10
|
+
source: 'config' | 'environment' | 'default' | 'path';
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const adbFileName = process.platform === 'win32' ? 'adb.exe' : 'adb';
|
|
14
|
+
|
|
15
|
+
function expandPath(value: string) {
|
|
16
|
+
const trimmed = value.trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
17
|
+
if (trimmed === '~') return os.homedir();
|
|
18
|
+
if (trimmed.startsWith(`~${path.sep}`) || trimmed.startsWith('~/')) {
|
|
19
|
+
return path.join(os.homedir(), trimmed.slice(2));
|
|
20
|
+
}
|
|
21
|
+
return path.isAbsolute(trimmed) ? path.normalize(trimmed) : appDataPath(trimmed);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sdkInfoFromPath(value: string, source: AndroidSdkInfo['source']): AndroidSdkInfo | null {
|
|
25
|
+
const candidate = expandPath(value);
|
|
26
|
+
const lowerBaseName = path.basename(candidate).toLowerCase();
|
|
27
|
+
const root = lowerBaseName === adbFileName.toLowerCase()
|
|
28
|
+
? path.dirname(path.dirname(candidate))
|
|
29
|
+
: lowerBaseName === 'platform-tools'
|
|
30
|
+
? path.dirname(candidate)
|
|
31
|
+
: candidate;
|
|
32
|
+
const adbPath = path.join(root, 'platform-tools', adbFileName);
|
|
33
|
+
if (!fs.existsSync(adbPath)) return null;
|
|
34
|
+
return { root, adbPath, source };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findAdbOnPath() {
|
|
38
|
+
const directories = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
39
|
+
for (const directory of directories) {
|
|
40
|
+
const adbPath = path.join(directory.replace(/^(["'])(.*)\1$/, '$2'), adbFileName);
|
|
41
|
+
if (fs.existsSync(adbPath)) return fs.realpathSync(adbPath);
|
|
42
|
+
}
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function defaultSdkPaths() {
|
|
47
|
+
if (process.platform === 'darwin') return [path.join(os.homedir(), 'Library', 'Android', 'sdk')];
|
|
48
|
+
if (process.platform === 'win32') {
|
|
49
|
+
return process.env.LOCALAPPDATA ? [path.join(process.env.LOCALAPPDATA, 'Android', 'Sdk')] : [];
|
|
50
|
+
}
|
|
51
|
+
return [path.join(os.homedir(), 'Android', 'Sdk')];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveAndroidSdk(): AndroidSdkInfo {
|
|
55
|
+
const configuredPath = loadConfig().runtime.androidSdkPath.trim();
|
|
56
|
+
if (configuredPath) {
|
|
57
|
+
const configured = sdkInfoFromPath(configuredPath, 'config');
|
|
58
|
+
if (configured) return configured;
|
|
59
|
+
throw new Error(`参数配置中的 Android SDK 路径无效:${configuredPath}。请选择包含 platform-tools/${adbFileName} 的 SDK 目录。`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const candidates = [process.env.ANDROID_SDK_ROOT, process.env.ANDROID_HOME]
|
|
63
|
+
.filter((value): value is string => Boolean(value));
|
|
64
|
+
for (const candidate of candidates) {
|
|
65
|
+
const resolved = sdkInfoFromPath(candidate, 'environment');
|
|
66
|
+
if (resolved) return resolved;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const candidate of defaultSdkPaths()) {
|
|
70
|
+
const resolved = sdkInfoFromPath(candidate, 'default');
|
|
71
|
+
if (resolved) return resolved;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const adbPath = findAdbOnPath();
|
|
75
|
+
if (adbPath) {
|
|
76
|
+
return {
|
|
77
|
+
root: path.dirname(path.dirname(adbPath)),
|
|
78
|
+
adbPath,
|
|
79
|
+
source: 'path',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw new Error('未检测到 Android SDK。请在“参数配置 > 运行配置”中指定 Android SDK 路径,或配置 ANDROID_SDK_ROOT/ANDROID_HOME。');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function ensureAndroidSdkAvailable() {
|
|
87
|
+
const sdk = resolveAndroidSdk();
|
|
88
|
+
process.env.ANDROID_HOME = sdk.root;
|
|
89
|
+
process.env.ANDROID_SDK_ROOT = sdk.root;
|
|
90
|
+
const platformTools = path.dirname(sdk.adbPath);
|
|
91
|
+
const pathEntries = (process.env.PATH || '').split(path.delimiter);
|
|
92
|
+
if (!pathEntries.includes(platformTools)) {
|
|
93
|
+
process.env.PATH = [platformTools, ...pathEntries].filter(Boolean).join(path.delimiter);
|
|
94
|
+
}
|
|
95
|
+
return sdk;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getAdbCommand() {
|
|
99
|
+
try {
|
|
100
|
+
return ensureAndroidSdkAvailable().adbPath;
|
|
101
|
+
} catch {
|
|
102
|
+
return process.platform === 'win32' ? 'adb.exe' : 'adb';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -3,6 +3,8 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { getAppiumRecordedScript, type AppiumRecordedScriptRecord, type AppiumRecordedStepRecord } from './repository';
|
|
5
5
|
import { appDataPath } from '../paths';
|
|
6
|
+
import { ensureAndroidSdkAvailable, getAdbCommand } from '../android-sdk';
|
|
7
|
+
import { createAppiumReplayReport } from './report';
|
|
6
8
|
|
|
7
9
|
type AppiumSessionResponse = {
|
|
8
10
|
value?: {
|
|
@@ -41,9 +43,13 @@ function wait(ms: number) {
|
|
|
41
43
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
function normalizeTextForContains(value: string) {
|
|
47
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
44
50
|
function adbTap(deviceId: string, x: number, y: number) {
|
|
45
51
|
return new Promise<void>((resolve, reject) => {
|
|
46
|
-
execFile(
|
|
52
|
+
execFile(getAdbCommand(), ['-s', deviceId, 'shell', 'input', 'tap', String(Math.round(x)), String(Math.round(y))], (error, _stdout, stderr) => {
|
|
47
53
|
if (error) {
|
|
48
54
|
reject(new Error(stderr || error.message));
|
|
49
55
|
return;
|
|
@@ -53,10 +59,10 @@ function adbTap(deviceId: string, x: number, y: number) {
|
|
|
53
59
|
});
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
function
|
|
62
|
+
export function launchAppOnDevice(deviceId: string, packageName: string) {
|
|
57
63
|
if (!packageName) return Promise.reject(new Error('启动 APP 缺少包名'));
|
|
58
64
|
return new Promise<void>((resolve, reject) => {
|
|
59
|
-
execFile(
|
|
65
|
+
execFile(getAdbCommand(), [
|
|
60
66
|
'-s',
|
|
61
67
|
deviceId,
|
|
62
68
|
'shell',
|
|
@@ -79,7 +85,7 @@ function adbLaunchApp(deviceId: string, packageName: string) {
|
|
|
79
85
|
|
|
80
86
|
function adbSwipe(deviceId: string, step: Required<AppiumRecordedStepRecord>['swipe']) {
|
|
81
87
|
return new Promise<void>((resolve, reject) => {
|
|
82
|
-
execFile(
|
|
88
|
+
execFile(getAdbCommand(), [
|
|
83
89
|
'-s',
|
|
84
90
|
deviceId,
|
|
85
91
|
'shell',
|
|
@@ -102,7 +108,7 @@ function adbSwipe(deviceId: string, step: Required<AppiumRecordedStepRecord>['sw
|
|
|
102
108
|
|
|
103
109
|
function adbText(deviceId: string, args: string[]) {
|
|
104
110
|
return new Promise<string>((resolve) => {
|
|
105
|
-
execFile(
|
|
111
|
+
execFile(getAdbCommand(), ['-s', deviceId, ...args], { maxBuffer: 4 * 1024 * 1024 }, (_error, stdout, stderr) => {
|
|
106
112
|
resolve((stdout || stderr || '').trim());
|
|
107
113
|
});
|
|
108
114
|
});
|
|
@@ -116,6 +122,12 @@ async function getCurrentActivity(deviceId: string) {
|
|
|
116
122
|
return resumedLine?.match(/\s([\w.$]+\/[\w.$]+)\s/)?.[1] || '';
|
|
117
123
|
}
|
|
118
124
|
|
|
125
|
+
async function isAppInForeground(deviceId: string, packageName: string) {
|
|
126
|
+
if (!packageName) return false;
|
|
127
|
+
const currentActivity = await getCurrentActivity(deviceId);
|
|
128
|
+
return currentActivity.split('/')[0] === packageName;
|
|
129
|
+
}
|
|
130
|
+
|
|
119
131
|
async function waitForActivity(deviceId: string, step: AppiumRecordedStepRecord) {
|
|
120
132
|
const expectedActivity = step.value || '';
|
|
121
133
|
if (!expectedActivity) throw new Error(`${step.label} 缺少目标 Activity`);
|
|
@@ -143,12 +155,13 @@ async function appendSettingsDiagnostics(lines: string[], deviceId: string) {
|
|
|
143
155
|
|
|
144
156
|
function appendAndroidSdkDiagnostics(lines: string[]) {
|
|
145
157
|
lines.push('Android SDK 环境变量未配置:');
|
|
158
|
+
lines.push('- 可在本项目“参数配置 > 运行配置”中指定 Android SDK 路径。');
|
|
146
159
|
lines.push('- Windows 常见 SDK 路径:%LOCALAPPDATA%\\Android\\Sdk');
|
|
147
160
|
lines.push('- PowerShell 设置示例:');
|
|
148
161
|
lines.push(' [Environment]::SetEnvironmentVariable("ANDROID_HOME", "$env:LOCALAPPDATA\\Android\\Sdk", "User")');
|
|
149
162
|
lines.push(' [Environment]::SetEnvironmentVariable("ANDROID_SDK_ROOT", "$env:LOCALAPPDATA\\Android\\Sdk", "User")');
|
|
150
163
|
lines.push(' [Environment]::SetEnvironmentVariable("Path", $env:Path + ";$env:LOCALAPPDATA\\Android\\Sdk\\platform-tools", "User")');
|
|
151
|
-
lines.push('-
|
|
164
|
+
lines.push('- Appium 是独立进程时不会继承网页中后设置的环境变量;设置后请重新打开终端,并重启 appium 服务。');
|
|
152
165
|
}
|
|
153
166
|
|
|
154
167
|
async function appiumRequest<T>(path: string, init?: RequestInit) {
|
|
@@ -192,6 +205,7 @@ async function appiumRequest<T>(path: string, init?: RequestInit) {
|
|
|
192
205
|
}
|
|
193
206
|
|
|
194
207
|
async function createSession(script: AppiumRecordedScriptRecord, deviceId: string) {
|
|
208
|
+
const appAlreadyForeground = await isAppInForeground(deviceId, script.appPackage).catch(() => false);
|
|
195
209
|
const payload = await appiumRequest<AppiumSessionResponse>('/session', {
|
|
196
210
|
method: 'POST',
|
|
197
211
|
body: JSON.stringify({
|
|
@@ -203,6 +217,7 @@ async function createSession(script: AppiumRecordedScriptRecord, deviceId: strin
|
|
|
203
217
|
'appium:appPackage': script.appPackage,
|
|
204
218
|
...(script.appActivity ? { 'appium:appActivity': script.appActivity } : {}),
|
|
205
219
|
'appium:noReset': true,
|
|
220
|
+
...(appAlreadyForeground ? { 'appium:autoLaunch': false } : {}),
|
|
206
221
|
'appium:skipDeviceInitialization': true,
|
|
207
222
|
'appium:ignoreHiddenApiPolicyError': true,
|
|
208
223
|
},
|
|
@@ -214,6 +229,17 @@ async function createSession(script: AppiumRecordedScriptRecord, deviceId: strin
|
|
|
214
229
|
return sessionId;
|
|
215
230
|
}
|
|
216
231
|
|
|
232
|
+
function isUiAutomationDisconnected(error: unknown) {
|
|
233
|
+
return errorDetail(error).includes('UiAutomation not connected');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function resetUiAutomator2(deviceId: string) {
|
|
237
|
+
await adbText(deviceId, ['shell', 'pkill', '-f', 'com.android.commands.uiautomator.Launcher']);
|
|
238
|
+
await adbText(deviceId, ['shell', 'am', 'force-stop', 'io.appium.uiautomator2.server']);
|
|
239
|
+
await adbText(deviceId, ['shell', 'am', 'force-stop', 'io.appium.uiautomator2.server.test']);
|
|
240
|
+
await wait(800);
|
|
241
|
+
}
|
|
242
|
+
|
|
217
243
|
function toAppiumUsing(selector: NonNullable<AppiumRecordedStepRecord['selector']>) {
|
|
218
244
|
if (selector.strategy === 'accessibilityId') return { using: 'accessibility id', value: selector.value || '' };
|
|
219
245
|
if (selector.strategy === 'id') return { using: 'id', value: selector.value || '' };
|
|
@@ -351,7 +377,10 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
|
|
|
351
377
|
|
|
352
378
|
if (step.type === 'launchApp') {
|
|
353
379
|
const packageName = step.value || '';
|
|
354
|
-
await
|
|
380
|
+
if (await isAppInForeground(deviceId, packageName).catch(() => false)) {
|
|
381
|
+
return `APP 已在前台,跳过启动 ${packageName}`;
|
|
382
|
+
}
|
|
383
|
+
await launchAppOnDevice(deviceId, packageName);
|
|
355
384
|
return `ADB 已启动 ${packageName}`;
|
|
356
385
|
}
|
|
357
386
|
|
|
@@ -517,10 +546,14 @@ async function evaluateCondition(sessionId: string, deviceId: string, step: Appi
|
|
|
517
546
|
await waitForElementGone(sessionId, step);
|
|
518
547
|
return true;
|
|
519
548
|
}
|
|
520
|
-
if (step.type === 'assertText') {
|
|
549
|
+
if (step.type === 'assertText' || (step.type === 'assertExists' && step.value)) {
|
|
521
550
|
const elementId = await findElement(sessionId, step);
|
|
522
551
|
const payload = await appiumRequest<AppiumValueResponse<string>>(`/session/${sessionId}/element/${elementId}/text`);
|
|
523
|
-
|
|
552
|
+
const actualText = payload.value || '';
|
|
553
|
+
const expectedText = step.value || '';
|
|
554
|
+
return step.flow?.textMatch === 'exact'
|
|
555
|
+
? actualText === expectedText
|
|
556
|
+
: normalizeTextForContains(actualText).includes(normalizeTextForContains(expectedText));
|
|
524
557
|
}
|
|
525
558
|
await waitForElement(sessionId, step);
|
|
526
559
|
return true;
|
|
@@ -574,12 +607,26 @@ async function replayLinkedScript(
|
|
|
574
607
|
const linkedScript = getAppiumRecordedScript(scriptId);
|
|
575
608
|
if (!linkedScript) throw new Error(`${step.label} 指向的脚本不存在`);
|
|
576
609
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
610
|
+
if (linkedScript.appActivity) {
|
|
611
|
+
const currentActivity = await getCurrentActivity(deviceId).catch(() => '');
|
|
612
|
+
if (currentActivity !== linkedScript.appActivity) {
|
|
613
|
+
lines.push(`等待连接脚本入口 Activity:${linkedScript.appActivity}`);
|
|
614
|
+
try {
|
|
615
|
+
await waitForActivity(deviceId, {
|
|
616
|
+
id: `${step.id}_wait_activity`,
|
|
617
|
+
type: 'waitActivity',
|
|
618
|
+
label: step.label,
|
|
619
|
+
value: linkedScript.appActivity,
|
|
620
|
+
timeoutMs: 10000,
|
|
621
|
+
});
|
|
622
|
+
} catch {
|
|
623
|
+
const latestActivity = await getCurrentActivity(deviceId).catch(() => currentActivity);
|
|
624
|
+
throw new Error(
|
|
625
|
+
`${step.label} 无法执行:当前 Activity 为 ${latestActivity || '-'},`
|
|
626
|
+
+ `目标脚本入口 Activity 为 ${linkedScript.appActivity}`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
583
630
|
}
|
|
584
631
|
|
|
585
632
|
lines.push(`连接脚本开始:${linkedScript.name}`);
|
|
@@ -600,24 +647,24 @@ async function replayLinearSteps(
|
|
|
600
647
|
) {
|
|
601
648
|
for (const [index, step] of steps.entries()) {
|
|
602
649
|
if (options.skipLaunchApp && step.type === 'launchApp') {
|
|
603
|
-
lines.push(`[
|
|
650
|
+
lines.push(`[节点 ${index + 1}] 跳过:${step.label}(连接脚本不重复启动 App)`);
|
|
604
651
|
continue;
|
|
605
652
|
}
|
|
606
|
-
lines.push(`[
|
|
653
|
+
lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
|
|
607
654
|
try {
|
|
608
655
|
if (step.type === 'runScript') {
|
|
609
656
|
await replayLinkedScript(sessionId, deviceId, step, lines, stack);
|
|
610
657
|
} else {
|
|
611
658
|
const result = await runStep(sessionId, deviceId, step);
|
|
612
|
-
if (result) lines.push(`[
|
|
659
|
+
if (result) lines.push(`[节点 ${index + 1}] 结果:${result}`);
|
|
613
660
|
}
|
|
614
|
-
lines.push(`[
|
|
661
|
+
lines.push(`[节点 ${index + 1}] 完成:${step.label}`);
|
|
615
662
|
} catch (error) {
|
|
616
663
|
if (step.optional) {
|
|
617
|
-
lines.push(`[
|
|
664
|
+
lines.push(`[节点 ${index + 1}] 跳过:${errorDetail(error)}`);
|
|
618
665
|
continue;
|
|
619
666
|
}
|
|
620
|
-
lines.push(`[
|
|
667
|
+
lines.push(`[节点 ${index + 1}] 失败:${errorDetail(error)}`);
|
|
621
668
|
await appendStepDiagnostics(lines, deviceId, step);
|
|
622
669
|
throw error;
|
|
623
670
|
}
|
|
@@ -633,6 +680,23 @@ async function replayFlowSteps(
|
|
|
633
680
|
options: ReplayStepOptions = {},
|
|
634
681
|
) {
|
|
635
682
|
const idToIndex = new Map(steps.map((step, index) => [step.id, index]));
|
|
683
|
+
const conditionContinuationIndex = (conditionId: string) => {
|
|
684
|
+
const conditionIndex = idToIndex.get(conditionId);
|
|
685
|
+
if (conditionIndex === undefined) return undefined;
|
|
686
|
+
const explicitTargetId = steps[conditionIndex]?.flow?.successTargetId;
|
|
687
|
+
return explicitTargetId ? idToIndex.get(explicitTargetId) : undefined;
|
|
688
|
+
};
|
|
689
|
+
const nextIndexAfterStep = (currentIndex: number, step: AppiumRecordedStepRecord) => {
|
|
690
|
+
const explicitTargetId = step.flow?.successTargetId;
|
|
691
|
+
if (explicitTargetId) return idToIndex.get(explicitTargetId);
|
|
692
|
+
if (!step.flow?.parentConditionId || !step.flow.parentBranch) return currentIndex + 1;
|
|
693
|
+
const nextStep = steps[currentIndex + 1];
|
|
694
|
+
const staysInBranch = nextStep?.flow?.parentConditionId === step.flow.parentConditionId
|
|
695
|
+
&& nextStep.flow.parentBranch === step.flow.parentBranch;
|
|
696
|
+
return staysInBranch
|
|
697
|
+
? currentIndex + 1
|
|
698
|
+
: conditionContinuationIndex(step.flow.parentConditionId);
|
|
699
|
+
};
|
|
636
700
|
const visitedPath: string[] = [];
|
|
637
701
|
let index: number | undefined = 0;
|
|
638
702
|
let guard = 0;
|
|
@@ -645,8 +709,7 @@ async function replayFlowSteps(
|
|
|
645
709
|
const step = steps[index];
|
|
646
710
|
if (options.skipLaunchApp && step.type === 'launchApp') {
|
|
647
711
|
lines.push(`[节点 ${index + 1}] 跳过:${step.label}(连接脚本不重复启动 App)`);
|
|
648
|
-
|
|
649
|
-
index = targetId ? idToIndex.get(targetId) : index + 1;
|
|
712
|
+
index = nextIndexAfterStep(index, step);
|
|
650
713
|
continue;
|
|
651
714
|
}
|
|
652
715
|
visitedPath.push(step.label);
|
|
@@ -655,9 +718,10 @@ async function replayFlowSteps(
|
|
|
655
718
|
|
|
656
719
|
if (nodeKind === 'condition') {
|
|
657
720
|
const matched = await evaluateCondition(sessionId, deviceId, step);
|
|
658
|
-
const
|
|
721
|
+
const branchTargetId = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
|
|
722
|
+
const targetId = branchTargetId || step.flow?.successTargetId || '';
|
|
659
723
|
lines.push(`[节点 ${index + 1}] 判断:${matched ? '是' : '否'}${targetId ? `,进入 ${targetId}` : ',流程结束'}`);
|
|
660
|
-
index = targetId ? idToIndex.get(targetId) :
|
|
724
|
+
index = targetId ? idToIndex.get(targetId) : undefined;
|
|
661
725
|
continue;
|
|
662
726
|
}
|
|
663
727
|
|
|
@@ -669,12 +733,11 @@ async function replayFlowSteps(
|
|
|
669
733
|
if (result) lines.push(`[节点 ${index + 1}] 结果:${result}`);
|
|
670
734
|
}
|
|
671
735
|
lines.push(`[节点 ${index + 1}] 完成:${step.label}`);
|
|
672
|
-
|
|
673
|
-
index = targetId ? idToIndex.get(targetId) : index + 1;
|
|
736
|
+
index = nextIndexAfterStep(index, step);
|
|
674
737
|
} catch (error) {
|
|
675
738
|
if (step.optional) {
|
|
676
739
|
lines.push(`[节点 ${index + 1}] 跳过:${errorDetail(error)}`);
|
|
677
|
-
index
|
|
740
|
+
index = nextIndexAfterStep(index, step);
|
|
678
741
|
continue;
|
|
679
742
|
}
|
|
680
743
|
const failureTarget = step.flow?.failureTargetId;
|
|
@@ -716,7 +779,11 @@ async function replayScriptSteps(
|
|
|
716
779
|
options: ReplayStepOptions = {},
|
|
717
780
|
) {
|
|
718
781
|
let trailingLinkIndex = steps.length;
|
|
719
|
-
while (
|
|
782
|
+
while (
|
|
783
|
+
trailingLinkIndex > 0
|
|
784
|
+
&& steps[trailingLinkIndex - 1]?.type === 'runScript'
|
|
785
|
+
&& !steps[trailingLinkIndex - 1]?.flow?.parentConditionId
|
|
786
|
+
) {
|
|
720
787
|
trailingLinkIndex -= 1;
|
|
721
788
|
}
|
|
722
789
|
|
|
@@ -730,26 +797,49 @@ async function replayScriptSteps(
|
|
|
730
797
|
}
|
|
731
798
|
}
|
|
732
799
|
|
|
733
|
-
export async function replayAppiumScript(
|
|
800
|
+
export async function replayAppiumScript(
|
|
801
|
+
script: AppiumRecordedScriptRecord,
|
|
802
|
+
deviceId: string,
|
|
803
|
+
onOutput?: (line: string) => void,
|
|
804
|
+
) {
|
|
734
805
|
const targetDeviceId = deviceId || script.deviceId;
|
|
735
806
|
if (!targetDeviceId) throw new Error('未检测到可用设备');
|
|
736
807
|
if (!script.steps.length) throw new Error('脚本没有可回放步骤');
|
|
737
808
|
|
|
738
|
-
const
|
|
809
|
+
const startedAt = new Date();
|
|
810
|
+
const lines: string[] = [];
|
|
811
|
+
const pushLine = lines.push.bind(lines);
|
|
812
|
+
lines.push = (...items: string[]) => {
|
|
813
|
+
const length = pushLine(...items);
|
|
814
|
+
items.forEach((line) => onOutput?.(line));
|
|
815
|
+
return length;
|
|
816
|
+
};
|
|
817
|
+
lines.push(
|
|
739
818
|
`Appium 服务:${appiumServerUrl()}`,
|
|
740
819
|
`目标设备:${targetDeviceId}`,
|
|
741
820
|
`App 包名:${script.appPackage}`,
|
|
742
821
|
`录制步骤:${script.steps.length}`,
|
|
743
|
-
|
|
822
|
+
);
|
|
744
823
|
let sessionId = '';
|
|
824
|
+
let success = false;
|
|
745
825
|
try {
|
|
826
|
+
lines.push('正在检测 Android SDK...');
|
|
827
|
+
const androidSdk = ensureAndroidSdkAvailable();
|
|
828
|
+
lines.push(`Android SDK 已就绪:${androidSdk.root}`);
|
|
746
829
|
lines.push('正在创建 Appium session...');
|
|
747
|
-
|
|
830
|
+
try {
|
|
831
|
+
sessionId = await createSession(script, targetDeviceId);
|
|
832
|
+
} catch (error) {
|
|
833
|
+
if (!isUiAutomationDisconnected(error)) throw error;
|
|
834
|
+
lines.push('检测到 UiAutomation 连接冲突,正在清理残留进程并重试...');
|
|
835
|
+
await resetUiAutomator2(targetDeviceId);
|
|
836
|
+
sessionId = await createSession(script, targetDeviceId);
|
|
837
|
+
}
|
|
748
838
|
lines.push(`Appium session 已创建:${sessionId}`);
|
|
749
839
|
if (hasFlowSteps(script.steps)) lines.push('按流程图路径回放...');
|
|
750
840
|
await replayScriptSteps(sessionId, targetDeviceId, script.steps, lines, [script.id]);
|
|
751
841
|
lines.push('回放完成');
|
|
752
|
-
|
|
842
|
+
success = true;
|
|
753
843
|
} catch (error) {
|
|
754
844
|
const detail = errorDetail(error);
|
|
755
845
|
if (detail.includes('Appium Settings app is not running')) {
|
|
@@ -759,10 +849,29 @@ export async function replayAppiumScript(script: AppiumRecordedScriptRecord, dev
|
|
|
759
849
|
appendAndroidSdkDiagnostics(lines);
|
|
760
850
|
}
|
|
761
851
|
lines.push(`回放终止:${detail}`);
|
|
762
|
-
return { success: false, output: lines.join('\n') };
|
|
763
852
|
} finally {
|
|
764
853
|
if (sessionId) {
|
|
765
854
|
await appiumRequest(`/session/${sessionId}`, { method: 'DELETE' }).catch(() => undefined);
|
|
766
855
|
}
|
|
767
856
|
}
|
|
857
|
+
|
|
858
|
+
const completedAt = new Date();
|
|
859
|
+
let reportPath = '';
|
|
860
|
+
let reportId = '';
|
|
861
|
+
try {
|
|
862
|
+
const report = await createAppiumReplayReport({
|
|
863
|
+
script,
|
|
864
|
+
deviceId: targetDeviceId,
|
|
865
|
+
success,
|
|
866
|
+
output: lines.join('\n'),
|
|
867
|
+
startedAt,
|
|
868
|
+
completedAt,
|
|
869
|
+
});
|
|
870
|
+
reportPath = report.filePath;
|
|
871
|
+
reportId = report.id;
|
|
872
|
+
lines.push(`回放报告已生成:${reportPath}`);
|
|
873
|
+
} catch (error) {
|
|
874
|
+
lines.push(`回放报告生成失败:${errorDetail(error)}`);
|
|
875
|
+
}
|
|
876
|
+
return { success, output: lines.join('\n'), reportPath, reportId };
|
|
768
877
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { appDataPath } from '../paths';
|
|
4
|
+
import { loadConfig } from '../config';
|
|
5
|
+
import { saveAppiumReplayReport, type AppiumRecordedScriptRecord, type AppiumRecordedStepRecord } from './repository';
|
|
6
|
+
|
|
7
|
+
function pad(value: number, length = 2) {
|
|
8
|
+
return String(value).padStart(length, '0');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fileDateTime(date: Date) {
|
|
12
|
+
return [
|
|
13
|
+
date.getFullYear(),
|
|
14
|
+
pad(date.getMonth() + 1),
|
|
15
|
+
pad(date.getDate()),
|
|
16
|
+
].join('-') + `_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}-${pad(date.getMilliseconds(), 3)}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function safeFileName(value: string) {
|
|
20
|
+
return value.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim() || '未命名脚本';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function markdownValue(value: unknown) {
|
|
24
|
+
if (value === undefined || value === null || value === '') return '-';
|
|
25
|
+
return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function selectorText(selector?: AppiumRecordedStepRecord['selector']) {
|
|
29
|
+
if (!selector) return '-';
|
|
30
|
+
if (selector.strategy === 'bounds') return `bounds (${selector.centerX ?? '-'}, ${selector.centerY ?? '-'})`;
|
|
31
|
+
return `${selector.strategy} ${selector.value || ''}`.trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stepExecutionLines(outputLines: string[], index: number) {
|
|
35
|
+
const prefixes = [`[节点 ${index + 1}]`, `[步骤 ${index + 1}]`];
|
|
36
|
+
return outputLines.filter((line) => prefixes.some((prefix) => line.startsWith(prefix)));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function executionStatus(lines: string[]) {
|
|
40
|
+
if (!lines.length) return '未执行';
|
|
41
|
+
if (lines.some((line) => line.includes('失败:') || line.includes('失败分支:'))) return '失败';
|
|
42
|
+
if (lines.some((line) => line.includes('跳过:'))) return '跳过';
|
|
43
|
+
if (lines.some((line) => line.includes('判断:'))) return '判断完成';
|
|
44
|
+
if (lines.some((line) => line.includes('完成:'))) return '成功';
|
|
45
|
+
return '中断';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stepSection(step: AppiumRecordedStepRecord, index: number, outputLines: string[]) {
|
|
49
|
+
const executionLines = stepExecutionLines(outputLines, index);
|
|
50
|
+
const config = JSON.stringify(step, null, 2);
|
|
51
|
+
return [
|
|
52
|
+
`### ${index + 1}. ${step.label}`,
|
|
53
|
+
'',
|
|
54
|
+
'| 配置项 | 值 |',
|
|
55
|
+
'| --- | --- |',
|
|
56
|
+
`| 节点 ID | ${markdownValue(step.id)} |`,
|
|
57
|
+
`| 操作类型 | ${markdownValue(step.type)} |`,
|
|
58
|
+
`| 节点类型 | ${markdownValue(step.flow?.nodeKind || 'action')} |`,
|
|
59
|
+
`| 备注 | ${markdownValue(step.note)} |`,
|
|
60
|
+
`| Selector | ${markdownValue(selectorText(step.selector))} |`,
|
|
61
|
+
`| 上下文 Selector | ${markdownValue(selectorText(step.contextSelector))} |`,
|
|
62
|
+
`| 输入/目标值 | ${markdownValue(step.value)} |`,
|
|
63
|
+
`| 超时时间 | ${markdownValue(step.timeoutMs === undefined ? '-' : `${step.timeoutMs}ms`)} |`,
|
|
64
|
+
`| 可选步骤 | ${step.optional ? '是' : '否'} |`,
|
|
65
|
+
`| 是分支 | ${markdownValue(step.flow?.yesTargetId)} |`,
|
|
66
|
+
`| 否分支 | ${markdownValue(step.flow?.noTargetId)} |`,
|
|
67
|
+
`| 成功后续节点 | ${markdownValue(step.flow?.successTargetId)} |`,
|
|
68
|
+
`| 执行状态 | **${executionStatus(executionLines)}** |`,
|
|
69
|
+
'',
|
|
70
|
+
'#### 执行信息',
|
|
71
|
+
'',
|
|
72
|
+
executionLines.length ? '```text' : '',
|
|
73
|
+
executionLines.length ? executionLines.join('\n') : '该节点未进入执行路径,或在前序失败后未执行。',
|
|
74
|
+
executionLines.length ? '```' : '',
|
|
75
|
+
'',
|
|
76
|
+
'<details>',
|
|
77
|
+
'<summary>完整节点配置</summary>',
|
|
78
|
+
'',
|
|
79
|
+
'```json',
|
|
80
|
+
config,
|
|
81
|
+
'```',
|
|
82
|
+
'</details>',
|
|
83
|
+
'',
|
|
84
|
+
].filter((line, lineIndex, lines) => line !== '' || lines[lineIndex - 1] !== '').join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function createAppiumReplayReport(input: {
|
|
88
|
+
script: AppiumRecordedScriptRecord;
|
|
89
|
+
deviceId: string;
|
|
90
|
+
success: boolean;
|
|
91
|
+
output: string;
|
|
92
|
+
startedAt: Date;
|
|
93
|
+
completedAt: Date;
|
|
94
|
+
}) {
|
|
95
|
+
const configuredOutputPath = loadConfig().runtime.reportOutputPath.trim();
|
|
96
|
+
const outputDir = appDataPath(configuredOutputPath || 'output');
|
|
97
|
+
await mkdir(outputDir, { recursive: true });
|
|
98
|
+
const fileName = `${fileDateTime(input.completedAt)}-${safeFileName(input.script.name)}.md`;
|
|
99
|
+
const filePath = join(outputDir, fileName);
|
|
100
|
+
const outputLines = input.output.split(/\r?\n/);
|
|
101
|
+
const durationMs = input.completedAt.getTime() - input.startedAt.getTime();
|
|
102
|
+
const markdown = [
|
|
103
|
+
'# Appium 回放报告',
|
|
104
|
+
'',
|
|
105
|
+
'| 项目 | 内容 |',
|
|
106
|
+
'| --- | --- |',
|
|
107
|
+
`| 脚本名称 | ${markdownValue(input.script.name)} |`,
|
|
108
|
+
`| 脚本 ID | ${markdownValue(input.script.id)} |`,
|
|
109
|
+
`| App 包名 | ${markdownValue(input.script.appPackage)} |`,
|
|
110
|
+
`| App Activity | ${markdownValue(input.script.appActivity)} |`,
|
|
111
|
+
`| 设备 ID | ${markdownValue(input.deviceId)} |`,
|
|
112
|
+
`| 开始时间 | ${input.startedAt.toLocaleString()} |`,
|
|
113
|
+
`| 完成时间 | ${input.completedAt.toLocaleString()} |`,
|
|
114
|
+
`| 总耗时 | ${durationMs}ms |`,
|
|
115
|
+
`| 执行结果 | **${input.success ? '成功' : '失败'}** |`,
|
|
116
|
+
`| 节点数量 | ${input.script.steps.length} |`,
|
|
117
|
+
'',
|
|
118
|
+
'## 节点明细',
|
|
119
|
+
'',
|
|
120
|
+
...input.script.steps.map((step, index) => stepSection(step, index, outputLines)),
|
|
121
|
+
'## 完整回放日志',
|
|
122
|
+
'',
|
|
123
|
+
'```text',
|
|
124
|
+
input.output || '-',
|
|
125
|
+
'```',
|
|
126
|
+
'',
|
|
127
|
+
].join('\n');
|
|
128
|
+
await writeFile(filePath, markdown, 'utf8');
|
|
129
|
+
const record = saveAppiumReplayReport({
|
|
130
|
+
scriptId: input.script.id,
|
|
131
|
+
scriptName: input.script.name,
|
|
132
|
+
success: input.success,
|
|
133
|
+
filePath,
|
|
134
|
+
startedAt: input.startedAt.toISOString(),
|
|
135
|
+
completedAt: input.completedAt.toISOString(),
|
|
136
|
+
});
|
|
137
|
+
return { id: record.id, filePath, fileName };
|
|
138
|
+
}
|