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 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
- 配置会保存到本地 SQLite:
99
+ 使用 `npx android-midscene-automation` 启动时,配置会保存到固定的系统用户数据目录:
100
100
 
101
101
  ```text
102
- .midscene-app/script-cache.sqlite
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: process.env.ANDROID_MIDSCENE_DATA_ROOT || userRoot,
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.26",
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
- sessionId: string,
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 || !sessionId) return;
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
- const payload = await appiumRequest<AppiumValueResponse<string>>(
328
- `/session/${sessionId}/screenshot`,
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(sessionId: string) {
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(payload.value, 'base64'));
526
+ await writeFile(file, Buffer.from(await adbScreenshotBase64(deviceId), 'base64'));
493
527
  }
494
528
 
495
- async function runStep(sessionId: string, deviceId: string, step: AppiumRecordedStepRecord) {
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(sessionId);
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
- if (result) lines.push(`[节点 ${index + 1}] 结果:${result}`);
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
- if (result) lines.push(`[节点 ${index + 1}] 结果:${result}`);
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.push('回放完成');
1075
- success = true;
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
  }