android-midscene-automation 0.1.26 → 0.1.27
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 +10 -0
- package/FAQ.md +14 -0
- package/README.md +31 -3
- package/bin/android-midscene-automation.js +71 -1
- package/package.json +4 -1
- package/server/appium-recorder/appium-runner.ts +273 -28
- package/server/appium-recorder/report.ts +186 -3
- package/server/appium-recorder/repository.ts +24 -3
- package/server/appium-recorder/visual-change.ts +184 -0
- package/server/http-api.ts +3 -4
- package/src/appium-recorder/AppiumPage.vue +465 -139
- package/src/appium-recorder/api.ts +1 -0
- package/src/appium-recorder/components/FlowCanvas.vue +14 -1
- package/src/appium-recorder/components/FlowStepEditor.vue +121 -2
- package/src/appium-recorder/components/NestedConditionBranches.vue +26 -4
- package/src/appium-recorder/components/RecordedSteps.vue +13 -1
- package/src/appium-recorder/components/VisualChangeDialog.vue +151 -0
- package/src/appium-recorder/flow-copy.ts +47 -1
- package/src/appium-recorder/flow-graph.ts +101 -6
- package/src/appium-recorder/flow-labels.ts +6 -1
- package/src/appium-recorder/flow-normalize.ts +67 -0
- package/src/appium-recorder/flow-remove.ts +67 -0
- package/src/appium-recorder/types.ts +25 -1
- package/src/appium-recorder/visual-change-pairs.ts +147 -0
- package/src/appium-recorder/visual-change.ts +75 -0
- package/src/components/device/DevicePreviewPanel.vue +148 -4
- package/src/style.css +39 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## v0.1.27
|
|
4
|
+
|
|
5
|
+
- `npx android-midscene-automation` 启动时默认使用固定的系统用户数据目录保存脚本、配置、报告和运行态文件,并在启动时尝试迁移旧版启动目录下的数据,降低升级后脚本或配置“不见了”的风险。
|
|
6
|
+
- 新增脚本列表复制和重命名能力,可直接生成“原文件名 Copy”副本并修改脚本名称,便于复用已有录制流程。
|
|
7
|
+
- 新增检测画面变化能力,支持框选设备预览区域或使用当前选中元素作为检测区域,通过“检测画面变化N-开始节点 / 结束节点”对同一分支内的前后截图进行差异对比。
|
|
8
|
+
- 检测画面变化结果写入 Markdown 和 HTML 回放报告,展示检测区域、阈值、最大变化比例、基准帧、对比帧和差异图,HTML 图片支持点击放大查看。
|
|
9
|
+
- 新增空节点,用于在顺序流程中作为无操作占位节点,配合画面变化检测或流程编排使用。
|
|
10
|
+
- 优化流程图节点插入、删除、复制、缩放和判断分支布局,修复节点新增后连线延迟、缩放后节点不可见、删除检测节点后分支错乱、重复添加同一结束节点等问题。
|
|
11
|
+
- 回放报告截图和截图节点改为使用 ADB 截图,避免 Appium `/screenshot` 超时后堵塞 UiAutomator2 命令队列;Appium 请求超时时也会显示更准确的超时提示。
|
|
12
|
+
|
|
3
13
|
## v0.1.26
|
|
4
14
|
|
|
5
15
|
- 修复流程节点复制与粘贴问题,避免 `structuredClone` 失败、粘贴后原节点消失、分支插入位置错误和复制后相对位置偏移。
|
package/FAQ.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# 常见问题
|
|
2
2
|
|
|
3
|
+
## 更新后脚本或配置不见了怎么办
|
|
4
|
+
|
|
5
|
+
通常不是数据被删除,而是旧版本把数据保存到了启动命令所在目录。新版 `npx android-midscene-automation` 会默认使用固定的系统用户数据目录,并在启动时尝试从当前目录自动迁移旧数据。
|
|
6
|
+
|
|
7
|
+
默认数据目录:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
Windows: %LOCALAPPDATA%\android-midscene-automation
|
|
11
|
+
macOS: ~/Library/Application Support/android-midscene-automation
|
|
12
|
+
Linux: ~/.local/share/android-midscene-automation
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
如果迁移后仍看不到旧数据,请在之前启动过的目录里查找 `.midscene-app/script-cache.sqlite`,再把 `.midscene-app` 复制到上面的固定数据目录。也可以设置 `ANDROID_MIDSCENE_DATA_ROOT` 指向原来的数据目录继续使用。
|
|
16
|
+
|
|
3
17
|
## 同一个 id 定位到错误输入框怎么办
|
|
4
18
|
|
|
5
19
|
如果账号框和密码框都是 `id/tg_edit`,只按 id 回放可能输入到第一个输入框。
|
package/README.md
CHANGED
|
@@ -96,12 +96,16 @@ http://127.0.0.1:5173/
|
|
|
96
96
|
- `Midscene模型`:执行测试脚本时使用,支持“自定义提供方”和“使用 Codex”。
|
|
97
97
|
- `AI生成脚本模型`:根据测试需求生成脚本时使用。
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
使用 `npx android-midscene-automation` 启动时,配置会保存到固定的系统用户数据目录:
|
|
100
100
|
|
|
101
101
|
```text
|
|
102
|
-
|
|
102
|
+
Windows: %LOCALAPPDATA%\android-midscene-automation\.midscene-app\script-cache.sqlite
|
|
103
|
+
macOS: ~/Library/Application Support/android-midscene-automation/.midscene-app/script-cache.sqlite
|
|
104
|
+
Linux: ~/.local/share/android-midscene-automation/.midscene-app/script-cache.sqlite
|
|
103
105
|
```
|
|
104
106
|
|
|
107
|
+
也可以通过 `ANDROID_MIDSCENE_DATA_ROOT` 指定自定义数据目录。使用 `npm run dev` 本地开发时,默认仍写入项目根目录。
|
|
108
|
+
|
|
105
109
|
旧版 `config.yaml` 或 `config.json` 会在读取后迁移到数据库。当前运行时会优先读取数据库中的模型配置。
|
|
106
110
|
|
|
107
111
|
## Android 自动化
|
|
@@ -149,7 +153,7 @@ appium
|
|
|
149
153
|
- Android 预览和操作:`GET /api/android-preview`、`POST /api/android-tap`、`POST /api/android-swipe`、`POST /api/android-keyevent`
|
|
150
154
|
- App 预设:`GET /api/app-presets`、`POST /api/save-app-preset`、`POST /api/delete-app-preset`
|
|
151
155
|
|
|
152
|
-
|
|
156
|
+
使用 `npx android-midscene-automation` 启动时,后端运行态文件默认都在系统用户数据目录下;使用 `npm run dev` 本地开发时,默认在项目根目录下。可以通过 `ANDROID_MIDSCENE_DATA_ROOT` 覆盖数据目录:
|
|
153
157
|
|
|
154
158
|
```text
|
|
155
159
|
.midscene-app/ # SQLite 数据库
|
|
@@ -1119,6 +1123,20 @@ output/2026-08-21_17-13-42-831-登录流程.html
|
|
|
1119
1123
|
|
|
1120
1124
|
# 常见问题
|
|
1121
1125
|
|
|
1126
|
+
## 更新后脚本或配置不见了怎么办
|
|
1127
|
+
|
|
1128
|
+
通常不是数据被删除,而是旧版本把数据保存到了启动命令所在目录。新版 `npx android-midscene-automation` 会默认使用固定的系统用户数据目录,并在启动时尝试从当前目录自动迁移旧数据。
|
|
1129
|
+
|
|
1130
|
+
默认数据目录:
|
|
1131
|
+
|
|
1132
|
+
```text
|
|
1133
|
+
Windows: %LOCALAPPDATA%\android-midscene-automation
|
|
1134
|
+
macOS: ~/Library/Application Support/android-midscene-automation
|
|
1135
|
+
Linux: ~/.local/share/android-midscene-automation
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
如果迁移后仍看不到旧数据,请在之前启动过的目录里查找 `.midscene-app/script-cache.sqlite`,再把 `.midscene-app` 复制到上面的固定数据目录。也可以设置 `ANDROID_MIDSCENE_DATA_ROOT` 指向原来的数据目录继续使用。
|
|
1139
|
+
|
|
1122
1140
|
## 同一个 id 定位到错误输入框怎么办
|
|
1123
1141
|
|
|
1124
1142
|
如果账号框和密码框都是 `id/tg_edit`,只按 id 回放可能输入到第一个输入框。
|
|
@@ -1211,6 +1229,16 @@ output/2026-08-21_17-13-42-831-登录流程.html
|
|
|
1211
1229
|
|
|
1212
1230
|
# 项目更新记录
|
|
1213
1231
|
|
|
1232
|
+
## v0.1.27
|
|
1233
|
+
|
|
1234
|
+
- `npx android-midscene-automation` 启动时默认使用固定的系统用户数据目录保存脚本、配置、报告和运行态文件,并在启动时尝试迁移旧版启动目录下的数据,降低升级后脚本或配置“不见了”的风险。
|
|
1235
|
+
- 新增脚本列表复制和重命名能力,可直接生成“原文件名 Copy”副本并修改脚本名称,便于复用已有录制流程。
|
|
1236
|
+
- 新增检测画面变化能力,支持框选设备预览区域或使用当前选中元素作为检测区域,通过“检测画面变化N-开始节点 / 结束节点”对同一分支内的前后截图进行差异对比。
|
|
1237
|
+
- 检测画面变化结果写入 Markdown 和 HTML 回放报告,展示检测区域、阈值、最大变化比例、基准帧、对比帧和差异图,HTML 图片支持点击放大查看。
|
|
1238
|
+
- 新增空节点,用于在顺序流程中作为无操作占位节点,配合画面变化检测或流程编排使用。
|
|
1239
|
+
- 优化流程图节点插入、删除、复制、缩放和判断分支布局,修复节点新增后连线延迟、缩放后节点不可见、删除检测节点后分支错乱、重复添加同一结束节点等问题。
|
|
1240
|
+
- 回放报告截图和截图节点改为使用 ADB 截图,避免 Appium `/screenshot` 超时后堵塞 UiAutomator2 命令队列;Appium 请求超时时也会显示更准确的超时提示。
|
|
1241
|
+
|
|
1214
1242
|
## v0.1.26
|
|
1215
1243
|
|
|
1216
1244
|
- 修复流程节点复制与粘贴问题,避免 `structuredClone` 失败、粘贴后原节点消失、分支插入位置错误和复制后相对位置偏移。
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
4
6
|
import path from 'node:path';
|
|
5
7
|
import { fileURLToPath } from 'node:url';
|
|
6
8
|
|
|
@@ -11,6 +13,73 @@ const viteBin = path.join(viteRoot, 'bin', 'vite.js');
|
|
|
11
13
|
const args = process.argv.slice(2);
|
|
12
14
|
const userRoot = process.cwd();
|
|
13
15
|
const githubUrl = 'https://github.com/oooooooko/android-midscene-automation';
|
|
16
|
+
const appName = 'android-midscene-automation';
|
|
17
|
+
const runtimeEntries = [
|
|
18
|
+
'.midscene-app',
|
|
19
|
+
'scripts-output',
|
|
20
|
+
'midscene_run',
|
|
21
|
+
'.midscene-generated',
|
|
22
|
+
'config.json',
|
|
23
|
+
'config.yaml',
|
|
24
|
+
'output',
|
|
25
|
+
];
|
|
26
|
+
const runtimeMarkerEntries = ['.midscene-app', 'scripts-output', 'midscene_run', '.midscene-generated'];
|
|
27
|
+
|
|
28
|
+
function getDefaultDataRoot() {
|
|
29
|
+
if (process.platform === 'win32') {
|
|
30
|
+
return path.join(
|
|
31
|
+
process.env.LOCALAPPDATA || process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
|
32
|
+
appName,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (process.platform === 'darwin') {
|
|
37
|
+
return path.join(os.homedir(), 'Library', 'Application Support', appName);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), appName);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function hasRuntimeData(root) {
|
|
44
|
+
return runtimeMarkerEntries.some((entry) => fs.existsSync(path.join(root, entry)));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function copyRuntimeEntry(source, target) {
|
|
48
|
+
const stat = fs.statSync(source);
|
|
49
|
+
if (stat.isDirectory()) {
|
|
50
|
+
fs.cpSync(source, target, { recursive: true, force: false, errorOnExist: false });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!fs.existsSync(target)) {
|
|
55
|
+
fs.copyFileSync(source, target);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function migrateLegacyDataRoot(sourceRoot, targetRoot) {
|
|
60
|
+
if (process.env.ANDROID_MIDSCENE_DATA_ROOT || path.resolve(sourceRoot) === path.resolve(targetRoot)) return;
|
|
61
|
+
if (!hasRuntimeData(sourceRoot)) return;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
fs.mkdirSync(targetRoot, { recursive: true });
|
|
65
|
+
let copied = false;
|
|
66
|
+
for (const entry of runtimeEntries) {
|
|
67
|
+
const source = path.join(sourceRoot, entry);
|
|
68
|
+
if (!fs.existsSync(source)) continue;
|
|
69
|
+
copyRuntimeEntry(source, path.join(targetRoot, entry));
|
|
70
|
+
copied = true;
|
|
71
|
+
}
|
|
72
|
+
if (copied) {
|
|
73
|
+
console.log(`已迁移本地数据目录:${sourceRoot} -> ${targetRoot}`);
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.warn(`迁移本地数据目录失败:${error instanceof Error ? error.message : String(error)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const dataRoot = process.env.ANDROID_MIDSCENE_DATA_ROOT || getDefaultDataRoot();
|
|
81
|
+
|
|
82
|
+
migrateLegacyDataRoot(userRoot, dataRoot);
|
|
14
83
|
|
|
15
84
|
if (!args.includes('--host')) {
|
|
16
85
|
args.unshift('127.0.0.1');
|
|
@@ -19,13 +88,14 @@ if (!args.includes('--host')) {
|
|
|
19
88
|
|
|
20
89
|
console.log(`\nGitHub 源码 / 二次修改:${githubUrl}`);
|
|
21
90
|
console.log(`问题反馈 / 功能建议:${githubUrl}/issues\n`);
|
|
91
|
+
console.log(`本地数据目录:${dataRoot}\n`);
|
|
22
92
|
|
|
23
93
|
const child = spawn(process.execPath, [viteBin, ...args], {
|
|
24
94
|
cwd: packageRoot,
|
|
25
95
|
env: {
|
|
26
96
|
...process.env,
|
|
27
97
|
ANDROID_MIDSCENE_PACKAGE_ROOT: process.env.ANDROID_MIDSCENE_PACKAGE_ROOT || packageRoot,
|
|
28
|
-
ANDROID_MIDSCENE_DATA_ROOT:
|
|
98
|
+
ANDROID_MIDSCENE_DATA_ROOT: dataRoot,
|
|
29
99
|
},
|
|
30
100
|
stdio: 'inherit',
|
|
31
101
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "android-midscene-automation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/oooooooko/android-midscene-automation.git"
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
"@vue-flow/core": "1.48.2",
|
|
49
49
|
"element-plus": "2.11.1",
|
|
50
50
|
"openai": "5.23.2",
|
|
51
|
+
"pixelmatch": "7.1.0",
|
|
52
|
+
"pngjs": "7.0.0",
|
|
51
53
|
"tsx": "4.23.0",
|
|
52
54
|
"typescript": "5.9.3",
|
|
53
55
|
"vite": "7.3.6",
|
|
@@ -68,6 +70,7 @@
|
|
|
68
70
|
"devDependencies": {
|
|
69
71
|
"@playwright/test": "1.61.1",
|
|
70
72
|
"@types/node": "24.13.2",
|
|
73
|
+
"@types/pngjs": "6.0.5",
|
|
71
74
|
"@types/word-extractor": "1.0.6",
|
|
72
75
|
"@vue/tsconfig": "0.7.0",
|
|
73
76
|
"vue-tsc": "3.3.6"
|
|
@@ -5,8 +5,9 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { getAppiumRecordedScript, type AppiumRecordedScriptRecord, type AppiumRecordedStepRecord } from './repository';
|
|
6
6
|
import { appDataPath } from '../paths';
|
|
7
7
|
import { ensureAndroidSdkAvailable, getAdbCommand } from '../android-sdk';
|
|
8
|
-
import { createAppiumReplayReport, type AppiumReplayFrame } from './report';
|
|
8
|
+
import { createAppiumReplayReport, type AppiumReplayFrame, type AppiumReplayVisualCheck } from './report';
|
|
9
9
|
import { startManagedAppiumServer, usesManagedAppiumServer } from './managed-appium';
|
|
10
|
+
import { compareVisualChangeFrames, type VisualChangeRegion } from './visual-change';
|
|
10
11
|
|
|
11
12
|
type AppiumSessionResponse = {
|
|
12
13
|
value?: {
|
|
@@ -34,11 +35,21 @@ class ReplayStoppedError extends Error {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
type PendingVisualChangeCheck = {
|
|
39
|
+
step: AppiumRecordedStepRecord;
|
|
40
|
+
nodeNumber: number;
|
|
41
|
+
scriptName: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
37
44
|
const replayContext = new AsyncLocalStorage<{
|
|
38
45
|
signal?: AbortSignal;
|
|
39
46
|
appiumLog: (line: string) => void;
|
|
40
47
|
serverUrl: string;
|
|
48
|
+
deviceId: string;
|
|
41
49
|
frames: AppiumReplayFrame[];
|
|
50
|
+
visualChecks: AppiumReplayVisualCheck[];
|
|
51
|
+
pendingVisualChecks: PendingVisualChangeCheck[];
|
|
52
|
+
softFailureCount: number;
|
|
42
53
|
}>();
|
|
43
54
|
|
|
44
55
|
const appiumServerUrl = () => replayContext.getStore()?.serverUrl || configuredAppiumServerUrl();
|
|
@@ -60,6 +71,11 @@ function errorDetail(error: unknown) {
|
|
|
60
71
|
return error.message;
|
|
61
72
|
}
|
|
62
73
|
|
|
74
|
+
function isTimeoutError(error: unknown) {
|
|
75
|
+
const detail = errorDetail(error).toLowerCase();
|
|
76
|
+
return detail.includes('timeout') || detail.includes('timed out');
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
function isStaleElementError(error: unknown) {
|
|
64
80
|
const detail = errorDetail(error);
|
|
65
81
|
return detail.includes('stale element reference') || detail.includes('does not exist in DOM anymore');
|
|
@@ -174,6 +190,29 @@ function adbText(deviceId: string, args: string[]) {
|
|
|
174
190
|
});
|
|
175
191
|
}
|
|
176
192
|
|
|
193
|
+
function adbScreenshotBase64(deviceId: string) {
|
|
194
|
+
return new Promise<string>((resolve, reject) => {
|
|
195
|
+
execFile(
|
|
196
|
+
getAdbCommand(),
|
|
197
|
+
['-s', deviceId, 'exec-out', 'screencap', '-p'],
|
|
198
|
+
{ encoding: 'buffer', maxBuffer: 20 * 1024 * 1024, timeout: 8000 },
|
|
199
|
+
(error, stdout, stderr) => {
|
|
200
|
+
if (error) {
|
|
201
|
+
const detail = Buffer.isBuffer(stderr) ? stderr.toString('utf8').trim() : String(stderr || '').trim();
|
|
202
|
+
reject(new Error(detail || error.message || 'ADB 截图失败'));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const imageBuffer = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout || '');
|
|
206
|
+
if (!imageBuffer.length) {
|
|
207
|
+
reject(new Error('ADB 未返回截图内容'));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
resolve(imageBuffer.toString('base64'));
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
177
216
|
async function getCurrentActivity(deviceId: string) {
|
|
178
217
|
const output = await adbText(deviceId, ['shell', 'dumpsys', 'activity', 'activities']);
|
|
179
218
|
const resumedLine = output
|
|
@@ -254,6 +293,9 @@ async function appiumRequest<T>(
|
|
|
254
293
|
throw new ReplayStoppedError();
|
|
255
294
|
}
|
|
256
295
|
context?.appiumLog(`[Appium] 异常:${method} ${path}(${errorDetail(error)})`);
|
|
296
|
+
if (isTimeoutError(error)) {
|
|
297
|
+
throw new Error(`Appium 请求超时 ${requestUrl},${errorDetail(error)}`);
|
|
298
|
+
}
|
|
257
299
|
throw new Error(`无法连接 Appium 服务 ${requestUrl},${errorDetail(error)}`);
|
|
258
300
|
}
|
|
259
301
|
|
|
@@ -291,7 +333,7 @@ function replayFrameSelector(step: AppiumRecordedStepRecord) {
|
|
|
291
333
|
}
|
|
292
334
|
|
|
293
335
|
async function captureReplayFrame(
|
|
294
|
-
|
|
336
|
+
_sessionId: string,
|
|
295
337
|
step: AppiumRecordedStepRecord,
|
|
296
338
|
nodeNumber: number,
|
|
297
339
|
scriptName: string,
|
|
@@ -299,7 +341,7 @@ async function captureReplayFrame(
|
|
|
299
341
|
status: string,
|
|
300
342
|
) {
|
|
301
343
|
const context = replayContext.getStore();
|
|
302
|
-
if (!context || !
|
|
344
|
+
if (!context || !context.deviceId) return;
|
|
303
345
|
const appendFrame = (imageBase64: string) => {
|
|
304
346
|
context.frames.push({
|
|
305
347
|
sequence: context.frames.length + 1,
|
|
@@ -324,14 +366,8 @@ async function captureReplayFrame(
|
|
|
324
366
|
}
|
|
325
367
|
|
|
326
368
|
try {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
undefined,
|
|
330
|
-
{ ignoreAbort: true, timeoutMs: 5000 },
|
|
331
|
-
);
|
|
332
|
-
const imageBase64 = typeof payload.value === 'string' ? payload.value : '';
|
|
333
|
-
if (!imageBase64) throw new Error('Appium 未返回截图内容');
|
|
334
|
-
appendFrame(imageBase64);
|
|
369
|
+
// Report screenshots use ADB so a slow capture cannot block Appium's command queue.
|
|
370
|
+
appendFrame(await adbScreenshotBase64(context.deviceId));
|
|
335
371
|
} catch (error) {
|
|
336
372
|
context.appiumLog(`[节点 ${nodeNumber}] 截图失败:${errorDetail(error)}`);
|
|
337
373
|
}
|
|
@@ -483,16 +519,183 @@ async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRec
|
|
|
483
519
|
throw new Error(`${step.label} 等待消失超时`);
|
|
484
520
|
}
|
|
485
521
|
|
|
486
|
-
async function saveScreenshot(
|
|
487
|
-
const payload = await appiumRequest<AppiumValueResponse<string>>(`/session/${sessionId}/screenshot`);
|
|
488
|
-
if (!payload.value) throw new Error('Appium 未返回截图数据');
|
|
522
|
+
async function saveScreenshot(deviceId: string) {
|
|
489
523
|
const dir = appDataPath('.midscene-app', 'screenshots');
|
|
490
524
|
await mkdir(dir, { recursive: true });
|
|
491
525
|
const file = join(dir, `appium-${Date.now()}.png`);
|
|
492
|
-
await writeFile(file, Buffer.from(
|
|
526
|
+
await writeFile(file, Buffer.from(await adbScreenshotBase64(deviceId), 'base64'));
|
|
493
527
|
}
|
|
494
528
|
|
|
495
|
-
|
|
529
|
+
type RunStepMeta = {
|
|
530
|
+
nodeNumber: number;
|
|
531
|
+
scriptName: string;
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
type RunStepResult = string | {
|
|
535
|
+
message?: string;
|
|
536
|
+
softFailed?: boolean;
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
function completedStepStatus(step: AppiumRecordedStepRecord, softFailed: boolean) {
|
|
540
|
+
if (step.type === 'visualChange') {
|
|
541
|
+
if (step.visualChange?.role === 'start') return '已记录基准帧';
|
|
542
|
+
if (step.visualChange?.role === 'end') return '已记录对比帧';
|
|
543
|
+
}
|
|
544
|
+
return softFailed ? '视觉变化未达预期' : '成功';
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function visualChangeNumber(value: unknown, fallback: number, min: number, max = Number.POSITIVE_INFINITY) {
|
|
548
|
+
const number = Number(value);
|
|
549
|
+
if (!Number.isFinite(number)) return fallback;
|
|
550
|
+
return Math.min(max, Math.max(min, number));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function visualChangeRegion(step: AppiumRecordedStepRecord): VisualChangeRegion {
|
|
554
|
+
const config = step.visualChange;
|
|
555
|
+
if (!config?.region) throw new Error(`${step.label} 缺少检测区域`);
|
|
556
|
+
return {
|
|
557
|
+
x: Math.max(0, Math.round(Number(config.region.x) || 0)),
|
|
558
|
+
y: Math.max(0, Math.round(Number(config.region.y) || 0)),
|
|
559
|
+
width: Math.max(1, Math.round(Number(config.region.width) || 1)),
|
|
560
|
+
height: Math.max(1, Math.round(Number(config.region.height) || 1)),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function findVisualFrame(
|
|
565
|
+
frames: AppiumReplayFrame[],
|
|
566
|
+
nodeId: string,
|
|
567
|
+
scriptName: string,
|
|
568
|
+
preferredPhase: AppiumReplayFrame['phase'],
|
|
569
|
+
) {
|
|
570
|
+
const matches = frames.filter((frame) => frame.nodeId === nodeId && frame.scriptName === scriptName);
|
|
571
|
+
if (!matches.length) return undefined;
|
|
572
|
+
const preferred = preferredPhase === 'before'
|
|
573
|
+
? matches.find((frame) => frame.phase === preferredPhase)
|
|
574
|
+
: [...matches].reverse().find((frame) => frame.phase === preferredPhase);
|
|
575
|
+
return preferred || (preferredPhase === 'before' ? matches[0] : matches[matches.length - 1]);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const EMPTY_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
|
|
579
|
+
|
|
580
|
+
function registerVisualChangeStep(
|
|
581
|
+
step: AppiumRecordedStepRecord,
|
|
582
|
+
meta: RunStepMeta,
|
|
583
|
+
): RunStepResult {
|
|
584
|
+
const config = step.visualChange;
|
|
585
|
+
if (!config) throw new Error(`${step.label} 缺少画面变化检测配置`);
|
|
586
|
+
if (config.role === 'start') {
|
|
587
|
+
return {
|
|
588
|
+
message: `已记录画面变化基准点:${config.pairLabel || step.label}`,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
const context = replayContext.getStore();
|
|
592
|
+
const endStep = {
|
|
593
|
+
...step,
|
|
594
|
+
visualChange: {
|
|
595
|
+
...config,
|
|
596
|
+
endStepId: config.endStepId || step.id,
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
context?.pendingVisualChecks.push({ step: endStep, nodeNumber: meta.nodeNumber, scriptName: meta.scriptName });
|
|
600
|
+
return {
|
|
601
|
+
message: `已记录画面变化对比点:${config.pairLabel || step.label}`,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function appendVisualCheckFailure(lines: string[], check: PendingVisualChangeCheck, message: string) {
|
|
606
|
+
const context = replayContext.getStore();
|
|
607
|
+
if (context) {
|
|
608
|
+
const config = check.step.visualChange;
|
|
609
|
+
context.softFailureCount += 1;
|
|
610
|
+
context.visualChecks.push({
|
|
611
|
+
nodeId: check.step.id,
|
|
612
|
+
nodeNumber: check.nodeNumber,
|
|
613
|
+
nodeLabel: check.step.label,
|
|
614
|
+
status: 'failed',
|
|
615
|
+
message,
|
|
616
|
+
region: config?.region ? visualChangeRegion(check.step) : { x: 0, y: 0, width: 1, height: 1 },
|
|
617
|
+
durationMs: 0,
|
|
618
|
+
intervalMs: 0,
|
|
619
|
+
sampleCount: 0,
|
|
620
|
+
changeRatioThreshold: visualChangeNumber(config?.changeRatioThreshold, 2, 0.01),
|
|
621
|
+
maxChangeRatio: 0,
|
|
622
|
+
baselineBase64: EMPTY_PNG_BASE64,
|
|
623
|
+
comparisonBase64: EMPTY_PNG_BASE64,
|
|
624
|
+
diffBase64: EMPTY_PNG_BASE64,
|
|
625
|
+
startNodeId: config?.startStepId,
|
|
626
|
+
endNodeId: config?.endStepId,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
lines.push(`[节点 ${check.nodeNumber}] 视觉变化检测异常:${message}(继续执行)`);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function processPendingVisualChecks(lines: string[]) {
|
|
633
|
+
const context = replayContext.getStore();
|
|
634
|
+
if (!context || !context.pendingVisualChecks.length) return;
|
|
635
|
+
|
|
636
|
+
// Visual checks compare captured replay frames after the path finishes, so
|
|
637
|
+
// they never block or mutate the device state for downstream nodes.
|
|
638
|
+
context.pendingVisualChecks.forEach((check) => {
|
|
639
|
+
const config = check.step.visualChange;
|
|
640
|
+
if (!config?.startStepId || !config.endStepId) {
|
|
641
|
+
appendVisualCheckFailure(lines, check, `${check.step.label} 缺少开始节点或结束节点`);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
const baselineFrame = findVisualFrame(context.frames, config.startStepId, check.scriptName, 'after');
|
|
645
|
+
const comparisonFrame = findVisualFrame(context.frames, config.endStepId, check.scriptName, 'after');
|
|
646
|
+
if (!baselineFrame || !comparisonFrame) {
|
|
647
|
+
const missing = [
|
|
648
|
+
baselineFrame ? '' : '开始节点截图',
|
|
649
|
+
comparisonFrame ? '' : '结束节点截图',
|
|
650
|
+
].filter(Boolean).join('、');
|
|
651
|
+
appendVisualCheckFailure(lines, check, `${check.step.label} 未捕获到${missing}`);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
let result: ReturnType<typeof compareVisualChangeFrames>;
|
|
655
|
+
try {
|
|
656
|
+
result = compareVisualChangeFrames({
|
|
657
|
+
baselineScreenshot: Buffer.from(baselineFrame.imageBase64, 'base64'),
|
|
658
|
+
comparisonScreenshot: Buffer.from(comparisonFrame.imageBase64, 'base64'),
|
|
659
|
+
region: visualChangeRegion(check.step),
|
|
660
|
+
changeRatioThreshold: visualChangeNumber(config.changeRatioThreshold, 2, 0.01),
|
|
661
|
+
pixelmatchThreshold: visualChangeNumber(config.pixelmatchThreshold, 0.1, 0, 1),
|
|
662
|
+
});
|
|
663
|
+
} catch (error) {
|
|
664
|
+
appendVisualCheckFailure(lines, check, `${check.step.label} 对比失败:${errorDetail(error)}`);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
context.visualChecks.push({
|
|
668
|
+
nodeId: check.step.id,
|
|
669
|
+
nodeNumber: check.nodeNumber,
|
|
670
|
+
nodeLabel: check.step.label,
|
|
671
|
+
status: result.passed ? 'passed' : 'failed',
|
|
672
|
+
message: result.message,
|
|
673
|
+
region: result.region,
|
|
674
|
+
durationMs: result.durationMs,
|
|
675
|
+
intervalMs: result.intervalMs,
|
|
676
|
+
sampleCount: result.sampleCount,
|
|
677
|
+
changeRatioThreshold: result.changeRatioThreshold,
|
|
678
|
+
maxChangeRatio: result.maxChangeRatio,
|
|
679
|
+
baselineBase64: result.baselineBase64,
|
|
680
|
+
comparisonBase64: result.comparisonBase64,
|
|
681
|
+
diffBase64: result.diffBase64,
|
|
682
|
+
startNodeId: config.startStepId,
|
|
683
|
+
startNodeLabel: `${baselineFrame.nodeNumber}. ${baselineFrame.nodeLabel}`,
|
|
684
|
+
endNodeId: config.endStepId,
|
|
685
|
+
endNodeLabel: `${comparisonFrame.nodeNumber}. ${comparisonFrame.nodeLabel}`,
|
|
686
|
+
});
|
|
687
|
+
if (!result.passed) context.softFailureCount += 1;
|
|
688
|
+
lines.push(`[节点 ${check.nodeNumber}] 视觉变化检测${result.passed ? '有变化' : '无明显变化'}:${result.message}${result.passed ? '' : '(继续执行)'}`);
|
|
689
|
+
});
|
|
690
|
+
context.pendingVisualChecks = [];
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function runStep(
|
|
694
|
+
sessionId: string,
|
|
695
|
+
deviceId: string,
|
|
696
|
+
step: AppiumRecordedStepRecord,
|
|
697
|
+
meta: RunStepMeta,
|
|
698
|
+
): Promise<RunStepResult | void> {
|
|
496
699
|
throwIfReplayStopped();
|
|
497
700
|
if (step.type === 'delay') {
|
|
498
701
|
await wait(Math.max(0, step.timeoutMs || 1000));
|
|
@@ -528,7 +731,15 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
|
|
|
528
731
|
}
|
|
529
732
|
|
|
530
733
|
if (step.type === 'screenshot') {
|
|
531
|
-
await saveScreenshot(
|
|
734
|
+
await saveScreenshot(deviceId);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (step.type === 'visualChange') {
|
|
739
|
+
return registerVisualChangeStep(step, meta);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (step.type === 'noop') {
|
|
532
743
|
return;
|
|
533
744
|
}
|
|
534
745
|
|
|
@@ -797,6 +1008,20 @@ async function replayLinkedScript(
|
|
|
797
1008
|
lines.push(`连接脚本完成:${linkedScript.name}`);
|
|
798
1009
|
}
|
|
799
1010
|
|
|
1011
|
+
function appendRunStepResult(lines: string[], nodeNumber: number, result: RunStepResult | void) {
|
|
1012
|
+
if (!result) return false;
|
|
1013
|
+
if (typeof result === 'string') {
|
|
1014
|
+
if (result) lines.push(`[节点 ${nodeNumber}] 结果:${result}`);
|
|
1015
|
+
return false;
|
|
1016
|
+
}
|
|
1017
|
+
if (result.softFailed) {
|
|
1018
|
+
lines.push(`[节点 ${nodeNumber}] 视觉变化未达预期:${result.message || '未检测到明显变化'}(继续执行)`);
|
|
1019
|
+
return true;
|
|
1020
|
+
}
|
|
1021
|
+
if (result.message) lines.push(`[节点 ${nodeNumber}] 结果:${result.message}`);
|
|
1022
|
+
return false;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
800
1025
|
async function replayLinearSteps(
|
|
801
1026
|
sessionId: string,
|
|
802
1027
|
deviceId: string,
|
|
@@ -814,14 +1039,18 @@ async function replayLinearSteps(
|
|
|
814
1039
|
lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
|
|
815
1040
|
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'before', '执行前');
|
|
816
1041
|
try {
|
|
1042
|
+
let softFailed = false;
|
|
817
1043
|
if (step.type === 'runScript') {
|
|
818
1044
|
await replayLinkedScript(sessionId, deviceId, step, lines, stack);
|
|
819
1045
|
} else {
|
|
820
|
-
const result = await runStep(sessionId, deviceId, step
|
|
821
|
-
|
|
1046
|
+
const result = await runStep(sessionId, deviceId, step, {
|
|
1047
|
+
nodeNumber: index + 1,
|
|
1048
|
+
scriptName: options.scriptName || '',
|
|
1049
|
+
});
|
|
1050
|
+
softFailed = appendRunStepResult(lines, index + 1, result);
|
|
822
1051
|
}
|
|
823
|
-
lines.push(`[节点 ${index + 1}] 完成:${step.label}`);
|
|
824
|
-
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after',
|
|
1052
|
+
lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
|
|
1053
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
|
|
825
1054
|
} catch (error) {
|
|
826
1055
|
if (isReplayStopped(error)) {
|
|
827
1056
|
lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
|
|
@@ -915,14 +1144,18 @@ async function replayFlowSteps(
|
|
|
915
1144
|
}
|
|
916
1145
|
|
|
917
1146
|
try {
|
|
1147
|
+
let softFailed = false;
|
|
918
1148
|
if (step.type === 'runScript') {
|
|
919
1149
|
await replayLinkedScript(sessionId, deviceId, step, lines, stack);
|
|
920
1150
|
} else {
|
|
921
|
-
const result = await runStep(sessionId, deviceId, step
|
|
922
|
-
|
|
1151
|
+
const result = await runStep(sessionId, deviceId, step, {
|
|
1152
|
+
nodeNumber: index + 1,
|
|
1153
|
+
scriptName: options.scriptName || '',
|
|
1154
|
+
});
|
|
1155
|
+
softFailed = appendRunStepResult(lines, index + 1, result);
|
|
923
1156
|
}
|
|
924
|
-
lines.push(`[节点 ${index + 1}] 完成:${step.label}`);
|
|
925
|
-
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after',
|
|
1157
|
+
lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
|
|
1158
|
+
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
|
|
926
1159
|
index = nextIndexAfterStep(index, step);
|
|
927
1160
|
} catch (error) {
|
|
928
1161
|
if (isReplayStopped(error)) {
|
|
@@ -997,7 +1230,7 @@ async function replayScriptSteps(
|
|
|
997
1230
|
try {
|
|
998
1231
|
await replayLinkedScript(sessionId, deviceId, step, lines, stack);
|
|
999
1232
|
lines.push(`[节点 ${nodeNumber}] 完成:${step.label}`);
|
|
1000
|
-
await captureReplayFrame(sessionId, step, nodeNumber, options.scriptName || '', 'after',
|
|
1233
|
+
await captureReplayFrame(sessionId, step, nodeNumber, options.scriptName || '', 'after', completedStepStatus(step, false));
|
|
1001
1234
|
} catch (error) {
|
|
1002
1235
|
if (isReplayStopped(error)) {
|
|
1003
1236
|
lines.push(`[节点 ${nodeNumber}] 已终止:${step.label}`);
|
|
@@ -1033,7 +1266,11 @@ export async function replayAppiumScript(
|
|
|
1033
1266
|
signal,
|
|
1034
1267
|
appiumLog: (line) => lines.push(line),
|
|
1035
1268
|
serverUrl: configuredAppiumServerUrl(),
|
|
1269
|
+
deviceId: targetDeviceId,
|
|
1036
1270
|
frames: [] as AppiumReplayFrame[],
|
|
1271
|
+
visualChecks: [] as AppiumReplayVisualCheck[],
|
|
1272
|
+
pendingVisualChecks: [] as PendingVisualChangeCheck[],
|
|
1273
|
+
softFailureCount: 0,
|
|
1037
1274
|
};
|
|
1038
1275
|
return replayContext.run(context, async () => {
|
|
1039
1276
|
lines.push(
|
|
@@ -1071,8 +1308,14 @@ export async function replayAppiumScript(
|
|
|
1071
1308
|
await replayScriptSteps(sessionId, targetDeviceId, script.steps, lines, [script.id], {
|
|
1072
1309
|
scriptName: script.name,
|
|
1073
1310
|
});
|
|
1074
|
-
lines
|
|
1075
|
-
|
|
1311
|
+
processPendingVisualChecks(lines);
|
|
1312
|
+
if (context.softFailureCount > 0) {
|
|
1313
|
+
lines.push(`回放完成,但存在 ${context.softFailureCount} 个视觉变化未达预期节点`);
|
|
1314
|
+
success = false;
|
|
1315
|
+
} else {
|
|
1316
|
+
lines.push('回放完成');
|
|
1317
|
+
success = true;
|
|
1318
|
+
}
|
|
1076
1319
|
} catch (error) {
|
|
1077
1320
|
stopped = isReplayStopped(error);
|
|
1078
1321
|
const detail = errorDetail(error);
|
|
@@ -1126,6 +1369,7 @@ export async function replayAppiumScript(
|
|
|
1126
1369
|
startedAt,
|
|
1127
1370
|
completedAt,
|
|
1128
1371
|
frames: context.frames,
|
|
1372
|
+
visualChecks: context.visualChecks,
|
|
1129
1373
|
});
|
|
1130
1374
|
reportPath = report.filePath;
|
|
1131
1375
|
reportId = report.id;
|
|
@@ -1145,6 +1389,7 @@ export async function replayAppiumScript(
|
|
|
1145
1389
|
reportId,
|
|
1146
1390
|
logPath,
|
|
1147
1391
|
htmlReportPath,
|
|
1392
|
+
softFailureCount: context.softFailureCount,
|
|
1148
1393
|
};
|
|
1149
1394
|
});
|
|
1150
1395
|
}
|