android-midscene-automation 0.1.34 → 0.1.36

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +10 -0
  3. package/package.json +2 -1
  4. package/server/appium-recorder/appium-runner.ts +39 -1
  5. package/server/appium-recorder/image-check.ts +160 -0
  6. package/server/appium-recorder/open-gallery.ts +10 -1
  7. package/server/appium-recorder/report.ts +41 -0
  8. package/server/appium-recorder/repository.ts +6 -0
  9. package/server/appium-recorder/routes.ts +19 -0
  10. package/server/appium-recorder/script-export.ts +1 -1
  11. package/src/appium-recorder/AppiumPage.vue +112 -8
  12. package/src/appium-recorder/action-descriptions.ts +46 -0
  13. package/src/appium-recorder/api.ts +5 -0
  14. package/src/appium-recorder/components/ComponentInspector.vue +9 -2
  15. package/src/appium-recorder/components/FlowActionMenu.vue +26 -5
  16. package/src/appium-recorder/components/FlowCanvas.vue +5 -4
  17. package/src/appium-recorder/components/FlowNodeCard.vue +9 -7
  18. package/src/appium-recorder/components/FlowStepEditor.vue +23 -4
  19. package/src/appium-recorder/components/ImageCheckDialog.vue +143 -0
  20. package/src/appium-recorder/components/MergeBranchesDialog.vue +31 -3
  21. package/src/appium-recorder/components/NestedConditionBranches.vue +6 -2
  22. package/src/appium-recorder/components/RecordedSteps.vue +7 -6
  23. package/src/appium-recorder/flow-copy.ts +6 -0
  24. package/src/appium-recorder/flow-graph.ts +22 -10
  25. package/src/appium-recorder/flow-insert.ts +27 -0
  26. package/src/appium-recorder/flow-labels.ts +4 -1
  27. package/src/appium-recorder/flow-merge.ts +12 -0
  28. package/src/appium-recorder/flow-unmerge.ts +145 -0
  29. package/src/appium-recorder/image-check.ts +117 -0
  30. package/src/appium-recorder/types.ts +15 -0
  31. package/src/appium-recorder/visual-change-pairs.ts +2 -0
  32. package/src/appium-recorder/visual-change.ts +13 -1
package/CHANGELOG.md CHANGED
@@ -1,4 +1,14 @@
1
1
  # 更新记录
2
+ ## v0.1.36
3
+ - 新合并支持取消:原合并按钮切换为取消合并,还原分支和被删除的重复节点,保留配置编辑;公共流程新增的普通操作可选择保留左侧、右侧或两侧,结构冲突时阻止还原。本地保存还原记录,导出及节点复制不携带历史记录。
4
+ - 启动相册前先停止当前设备用户下的目标相册进程,再重新打开;保留相册自动适配,不清除照片及应用数据,停止失败时明确报错。
5
+ - 修复修改元素或父级定位后仍使用旧备用定位、画面变化像素容差无法设为 0 的问题。
6
+
7
+ ## v0.1.35
8
+ - 新增“图像判断”分支节点,支持本地 OpenCV 模板匹配、图片勾选双模板、黑屏、区域颜色和多帧变化检测;支持组件定位、框选区域、模板采集与上传、连续采样及 true/false 分支,证据不足时明确报告无法判定。HTML 报告展示指标和可放大的检测图片,并沿用敏感运行截图保护。
9
+ - 合流后的公共节点上方新增插入入口,支持新增和粘贴操作,所有汇入分支先执行新操作再进入原公共节点。
10
+ - 修复嵌套判断显式合流到外层公共节点时连接被过滤、公共节点提前排列的问题,支持跨层分支合流。
11
+
2
12
  ## v0.1.34
3
13
  - 修复同一分支内显式跳转被画布误画为顺序连接的问题,失效跳转目标不再错误连接到相邻节点。
4
14
  - 修复判断分支默认返回公共后续节点时画布漏连线、节点重叠的问题,嵌套分支显示与回放路径保持一致。
package/README.md CHANGED
@@ -1229,6 +1229,16 @@ Linux: ~/.local/share/android-midscene-automation
1229
1229
 
1230
1230
  # 项目更新记录
1231
1231
 
1232
+ ## v0.1.36
1233
+ - 新合并支持取消:原合并按钮切换为取消合并,还原分支和被删除的重复节点,保留配置编辑;公共流程新增的普通操作可选择保留左侧、右侧或两侧,结构冲突时阻止还原。本地保存还原记录,导出及节点复制不携带历史记录。
1234
+ - 启动相册前先停止当前设备用户下的目标相册进程,再重新打开;保留相册自动适配,不清除照片及应用数据,停止失败时明确报错。
1235
+ - 修复修改元素或父级定位后仍使用旧备用定位、画面变化像素容差无法设为 0 的问题。
1236
+
1237
+ ## v0.1.35
1238
+ - 新增“图像判断”分支节点,支持本地 OpenCV 模板匹配、图片勾选双模板、黑屏、区域颜色和多帧变化检测;支持组件定位、框选区域、模板采集与上传、连续采样及 true/false 分支,证据不足时明确报告无法判定。HTML 报告展示指标和可放大的检测图片,并沿用敏感运行截图保护。
1239
+ - 合流后的公共节点上方新增插入入口,支持新增和粘贴操作,所有汇入分支先执行新操作再进入原公共节点。
1240
+ - 修复嵌套判断显式合流到外层公共节点时连接被过滤、公共节点提前排列的问题,支持跨层分支合流。
1241
+
1232
1242
  ## v0.1.34
1233
1243
  - 修复同一分支内显式跳转被画布误画为顺序连接的问题,失效跳转目标不再错误连接到相邻节点。
1234
1244
  - 修复判断分支默认返回公共后续节点时画布漏连线、节点重叠的问题,嵌套分支显示与回放路径保持一致。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
@@ -44,6 +44,7 @@
44
44
  "@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.5.tgz",
45
45
  "@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.5.tgz",
46
46
  "@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.5.tgz",
47
+ "@techstark/opencv-js": "5.0.0-release.1",
47
48
  "@vitejs/plugin-vue": "6.0.7",
48
49
  "@vue-flow/core": "1.48.2",
49
50
  "archiver": "7.0.1",
@@ -7,6 +7,8 @@ import { BoundedLoopTraversal } from './bounded-loop';
7
7
  import { validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
8
8
  import { adbScreenshotBase64 } from './screenshot';
9
9
  import { recognizeDeviceScreen } from './ai-recognition';
10
+ import { checkImage } from './image-check';
11
+ import { IMAGE_CHECK_MODES, type ImageCheckResult } from '../../src/appium-recorder/image-check';
10
12
  import { formatStageLog } from '../../src/appium-recorder/stage-log';
11
13
  import { openGalleryOnDevice } from './open-gallery';
12
14
  import { textClickSelector } from '../../src/appium-recorder/text-click';
@@ -66,6 +68,7 @@ const replayContext = new AsyncLocalStorage<{
66
68
  frames: AppiumReplayFrame[];
67
69
  historyEvents: AppiumReplayFrame[];
68
70
  visualChecks: AppiumReplayVisualCheck[];
71
+ imageChecks: Array<ImageCheckResult & { nodeId: string; nodeNumber: number; nodeLabel: string; scriptName: string; settings: string }>;
69
72
  pendingVisualChecks: PendingVisualChangeCheck[];
70
73
  visualStartOffsets: Map<string, number>;
71
74
  softFailureCount: number;
@@ -470,6 +473,26 @@ async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
470
473
  }
471
474
  }
472
475
 
476
+ // 图像判断不能使用通用定位的“第一个匹配”及坐标回退,父级和子级均须唯一。
477
+ async function imageCheckRegion(sessionId: string, step: AppiumRecordedStepRecord) {
478
+ async function unique(selector: AppiumRecordedStepRecord['selector'], parent?: string) {
479
+ if (!selector || selector.strategy === 'bounds') throw new Error('缺少可唯一定位的组件信息');
480
+ const path = parent ? `/session/${sessionId}/element/${parent}/elements` : `/session/${sessionId}/elements`;
481
+ const payload = await appiumRequest<AppiumValueResponse<Array<Record<string, string>>>>(path, {
482
+ method: 'POST', body: JSON.stringify(toAppiumUsing(selector)),
483
+ });
484
+ if (payload.value?.length !== 1) throw new Error(`目标组件匹配 ${payload.value?.length || 0} 个,请限定父级或条目文字`);
485
+ const id = payload.value[0]?.[ELEMENT_KEY] || payload.value[0]?.ELEMENT;
486
+ if (!id) throw new Error('目标组件缺少元素标识');
487
+ return id;
488
+ }
489
+ const parent = step.contextSelector ? await unique(step.contextSelector) : undefined;
490
+ const id = await unique(step.selector, parent);
491
+ const rect = await appiumRequest<AppiumValueResponse<VisualChangeRegion>>(`/session/${sessionId}/element/${id}/rect`);
492
+ if (!rect.value) throw new Error('无法读取组件实时区域');
493
+ return rect.value;
494
+ }
495
+
473
496
  async function tapFallback(deviceId: string, step: AppiumRecordedStepRecord) {
474
497
  if (step.fallback?.strategy !== 'bounds' || !Number.isFinite(step.fallback.centerX) || !Number.isFinite(step.fallback.centerY)) {
475
498
  throw new Error(`${step.label} 未找到元素,且没有可用坐标兜底`);
@@ -1220,7 +1243,20 @@ async function replayFlowSteps(
1220
1243
  signal: replayContext.getStore()?.signal,
1221
1244
  imageBase64: beforeFrame?.nodeId === step.id && beforeFrame.phase === 'before' ? beforeFrame.imageBase64 : undefined,
1222
1245
  }) : undefined;
1223
- const matched = recognition ? recognition.result : await evaluateCondition(sessionId, deviceId, step);
1246
+ let imageResult: ImageCheckResult | undefined;
1247
+ if (step.type === 'imageCheck') {
1248
+ const resolved = resolveVariableStep(step);
1249
+ imageResult = await checkImage({ config: resolved.imageCheck!,
1250
+ capture: async () => Buffer.from(await adbScreenshotBase64(deviceId, replayContext.getStore()?.signal), 'base64'),
1251
+ resolveRegion: () => imageCheckRegion(sessionId, resolved), wait,
1252
+ signal: replayContext.getStore()?.signal, saveImages: !variableContext.getStore()?.privacy.enabled });
1253
+ const config = resolved.imageCheck;
1254
+ const settings = config ? `${IMAGE_CHECK_MODES[config.mode]};条件 ${config.expectation};模板阈值 ${config.threshold};最小得分差 ${config.minScoreGap};RGB/亮度容差 ${config.tolerance};目标色 ${config.color};像素占比 ${config.ratio}%;观察 ${config.durationMs}ms;间隔 ${config.intervalMs}ms;连续 ${config.consecutive} 帧` : '缺少配置';
1255
+ replayContext.getStore()?.imageChecks.push({ ...imageResult, nodeId: step.id, nodeNumber: index + 1, nodeLabel: frameStep.label, scriptName: options.scriptName || '', settings });
1256
+ lines.push(`[节点 ${index + 1}] 图像判断:${imageResult.result === null ? '无法判定' : imageResult.result},${imageResult.message};${settings};采样 ${imageResult.sampleCount} 帧,耗时 ${imageResult.durationMs}ms;指标 ${JSON.stringify(imageResult.metrics)}`);
1257
+ if (imageResult.result === null) throw new Error(imageResult.message);
1258
+ }
1259
+ const matched = imageResult ? imageResult.result! : recognition ? recognition.result : await evaluateCondition(sessionId, deviceId, step);
1224
1260
  if (recognition) lines.push(`[节点 ${index + 1}] AI 识别:${recognition.result},耗时 ${recognition.durationMs}ms${recognition.reason ? `,依据:${recognition.reason}` : ''}`);
1225
1261
  const branchTargetId = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
1226
1262
  const next = traversal.branch(step, matched);
@@ -1407,6 +1443,7 @@ export async function replayAppiumScript(
1407
1443
  frames: [] as AppiumReplayFrame[],
1408
1444
  historyEvents: [] as AppiumReplayFrame[],
1409
1445
  visualChecks: [] as AppiumReplayVisualCheck[],
1446
+ imageChecks: [] as Array<ImageCheckResult & { nodeId: string; nodeNumber: number; nodeLabel: string; scriptName: string; settings: string }>,
1410
1447
  visualStartOffsets: new Map<string, number>(),
1411
1448
  pendingVisualChecks: [] as PendingVisualChangeCheck[],
1412
1449
  softFailureCount: 0,
@@ -1514,6 +1551,7 @@ export async function replayAppiumScript(
1514
1551
  completedAt,
1515
1552
  frames: safeFrames,
1516
1553
  visualChecks: safeChecks,
1554
+ imageChecks: scope.scrub(context.imageChecks.map(check => scope.privacy.enabled ? { ...check, images: [] } : check)),
1517
1555
  });
1518
1556
  reportPath = report.filePath;
1519
1557
  reportId = report.id;
@@ -0,0 +1,160 @@
1
+ import { PNG } from 'pngjs';
2
+ import { validateImageCheck, type ImageCheckConfig, type ImageCheckResult } from '../../src/appium-recorder/image-check';
3
+ import type { AppiumVisualChangeRegion } from '../../src/appium-recorder/types';
4
+
5
+ // 延迟初始化,普通回放不加载 WASM;仅缓存运行库,不缓存设备截图或检测结果。
6
+ let runtime: Promise<typeof import('@techstark/opencv-js')> | undefined;
7
+ async function openCv() {
8
+ runtime ??= import('@techstark/opencv-js').then(async module => {
9
+ const cv = await module.default;
10
+ if (!cv.Mat) await new Promise<void>(resolve => { cv.onRuntimeInitialized = resolve; });
11
+ return cv;
12
+ });
13
+ return runtime;
14
+ }
15
+
16
+ export function readImage(buffer: Buffer) {
17
+ // 先检查 IHDR 尺寸,再解码,防止超大图片耗尽内存。
18
+ if (buffer.length < 24 || buffer.length > 24 * 1024 * 1024 || buffer.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a') throw new Error('截图或模板不是有效 PNG');
19
+ const width = buffer.readUInt32BE(16), height = buffer.readUInt32BE(20);
20
+ if (!width || !height || width * height > 16000000) throw new Error('图片尺寸过大或无效');
21
+ return PNG.sync.read(buffer);
22
+ }
23
+
24
+ export function cropImage(source: PNG, region: AppiumVisualChangeRegion) {
25
+ const { x, y, width, height } = region;
26
+ if (![x, y, width, height].every(Number.isInteger) || x < 0 || y < 0 || width < 1 || height < 1
27
+ || x + width > source.width || y + height > source.height || width * height > 4000000) throw new Error('目标区域无效、越界或超过 400 万像素');
28
+ const image = new PNG({ width, height });
29
+ PNG.bitblt(source, image, x, y, width, height, 0, 0);
30
+ return image;
31
+ }
32
+
33
+ async function measure(image: PNG, baseline: PNG | undefined, templates: PNG[], config: ImageCheckConfig) {
34
+ const cv = await openCv();
35
+ const mat = cv.matFromArray(image.height, image.width, cv.CV_8UC4, image.data);
36
+ const rgb = new cv.Mat();
37
+ try {
38
+ cv.cvtColor(mat, rgb, cv.COLOR_RGBA2RGB);
39
+ if (config.mode === 'template' || config.mode === 'state') {
40
+ const scores = templates.map(template => {
41
+ if (template.width > image.width || template.height > image.height) throw new Error(`模板 ${template.width}×${template.height} 超过实时检测区域 ${image.width}×${image.height},请扩大检测区域或重新采集对应组件的模板`);
42
+ const raw = cv.matFromArray(template.height, template.width, cv.CV_8UC4, template.data);
43
+ const target = new cv.Mat(), output = new cv.Mat(), mask = new cv.Mat();
44
+ try {
45
+ cv.cvtColor(raw, target, cv.COLOR_RGBA2RGB);
46
+ // SQDIFF 不会把零方差的纯色模板错误识别成满分;统一转换为越大越匹配的得分。
47
+ cv.matchTemplate(rgb, target, output, cv.TM_SQDIFF);
48
+ const distance = cv.minMaxLoc(output, mask).minVal / (template.width * template.height * 3 * 255 * 255);
49
+ return Math.max(0, Math.min(1, 1 - Math.sqrt(Math.max(0, distance))));
50
+ } finally { raw.delete(); target.delete(); output.delete(); mask.delete(); }
51
+ });
52
+ const positive = scores[0]!;
53
+ if (config.mode === 'template') return { matched: config.expectation === 'present' ? positive >= config.threshold : positive < config.threshold, metrics: { '模板得分': positive } };
54
+ const negative = scores[1]!;
55
+ const gap = Math.abs(positive - negative);
56
+ return { matched: Math.max(positive, negative) < config.threshold || gap < config.minScoreGap ? null : positive > negative,
57
+ metrics: { '选中得分': positive, '未选中得分': negative, '得分差': gap } };
58
+ }
59
+ const mask = new cv.Mat();
60
+ try {
61
+ if (config.mode === 'black') {
62
+ const gray = new cv.Mat();
63
+ try { cv.cvtColor(rgb, gray, cv.COLOR_RGB2GRAY); cv.threshold(gray, mask, config.tolerance, 255, cv.THRESH_BINARY_INV); }
64
+ finally { gray.delete(); }
65
+ } else if (config.mode === 'color') {
66
+ const channels = [1, 3, 5].map(index => parseInt(config.color.slice(index, index + 2), 16));
67
+ const low = new cv.Mat(rgb.rows, rgb.cols, rgb.type(), new cv.Scalar(...channels.map(v => Math.max(0, v - config.tolerance)), 0));
68
+ const high = new cv.Mat(rgb.rows, rgb.cols, rgb.type(), new cv.Scalar(...channels.map(v => Math.min(255, v + config.tolerance)), 255));
69
+ try { cv.inRange(rgb, low, high, mask); } finally { low.delete(); high.delete(); }
70
+ } else {
71
+ if (!baseline || baseline.width !== image.width || baseline.height !== image.height) throw new Error('采样期间目标区域尺寸发生变化');
72
+ const first = cv.matFromArray(baseline.height, baseline.width, cv.CV_8UC4, baseline.data);
73
+ const diff = new cv.Mat();
74
+ try {
75
+ cv.absdiff(mat, first, diff);
76
+ // 按 RGB 任一通道差异统计,避免不同颜色在灰度化后抵消。
77
+ const bytes = new Uint8Array(image.width * image.height);
78
+ for (let i = 0; i < bytes.length; i++) bytes[i] = Math.max(diff.data[i * 4]!, diff.data[i * 4 + 1]!, diff.data[i * 4 + 2]!) > config.tolerance ? 255 : 0;
79
+ const binary = cv.matFromArray(image.height, image.width, cv.CV_8UC1, bytes);
80
+ try { binary.copyTo(mask); } finally { binary.delete(); }
81
+ } finally { first.delete(); diff.delete(); }
82
+ }
83
+ const ratio = cv.countNonZero(mask) / (image.width * image.height) * 100;
84
+ return { matched: ratio >= config.ratio, metrics: { [config.mode === 'black' ? '暗色像素占比 %' : config.mode === 'color' ? '目标颜色占比 %' : '变化比例 %']: ratio } };
85
+ } finally { mask.delete(); }
86
+ } finally { mat.delete(); rgb.delete(); }
87
+ }
88
+
89
+ export async function checkImage(input: {
90
+ config: ImageCheckConfig;
91
+ capture: () => Promise<Buffer>;
92
+ resolveRegion?: () => Promise<AppiumVisualChangeRegion>;
93
+ signal?: AbortSignal;
94
+ wait: (ms: number) => Promise<void>;
95
+ now?: () => number;
96
+ saveImages?: boolean;
97
+ }): Promise<ImageCheckResult> {
98
+ const now = input.now || Date.now, started = now();
99
+ const config = input.config;
100
+ const result: ImageCheckResult = { result: null, mode: config?.mode, region: config?.region, durationMs: 0, sampleCount: 0, metrics: {}, images: [], message: '' };
101
+ try {
102
+ validateImageCheck(config);
103
+ await openCv();
104
+ const templates = ['template', 'state'].includes(config.mode)
105
+ ? [config.template!, ...(config.mode === 'state' ? [config.negativeTemplate!] : [])].map(value => readImage(Buffer.from(value, 'base64'))) : [];
106
+ for (const template of templates) {
107
+ for (let i = 3; i < template.data.length; i += 4) if (template.data[i] !== 255) throw new Error('模板含透明像素,请使用设备实际截图采集');
108
+ }
109
+ let baseline: PNG | undefined, last: PNG | undefined, consecutive = 0;
110
+ let maximum = 0, observedAt = now();
111
+ const keep = (label: string, image: PNG) => {
112
+ if (input.saveImages !== false) result.images.push({ label, base64: PNG.sync.write(image).toString('base64') });
113
+ };
114
+ templates.forEach((image, index) => keep(index ? '未选中模板' : config.mode === 'state' ? '选中模板' : '模板', image));
115
+ while (true) {
116
+ input.signal?.throwIfAborted();
117
+ const region = config.target === 'element' ? await input.resolveRegion?.() : config.region;
118
+ if (!region) throw new Error('未找到目标元素');
119
+ const source = readImage(await input.capture());
120
+ input.signal?.throwIfAborted();
121
+ if (config.target === 'region' && (source.width !== config.screenWidth || source.height !== config.screenHeight)) throw new Error('屏幕尺寸与采集时不一致,请重新框选区域');
122
+ const current = cropImage(source, region);
123
+ result.region = region;
124
+ result.sampleCount++;
125
+ last = current;
126
+ if (!baseline) { baseline = current; observedAt = now(); keep('首帧', current); }
127
+ const measurement = await measure(current, baseline, templates, config);
128
+ result.metrics = { ...measurement.metrics };
129
+ input.signal?.throwIfAborted();
130
+ if (config.mode === 'state') {
131
+ if (measurement.matched === null) throw new Error('双模板均未达到阈值或得分过于接近');
132
+ result.result = measurement.matched;
133
+ break;
134
+ }
135
+ if (config.mode === 'change') {
136
+ maximum = Math.max(maximum, result.metrics['变化比例 %'] || 0);
137
+ result.metrics['最大变化比例 %'] = maximum;
138
+ const changed = maximum >= config.ratio;
139
+ if (changed || (result.sampleCount >= 2 && now() - observedAt >= config.durationMs)) {
140
+ result.result = config.expectation === 'present' ? changed : !changed;
141
+ break;
142
+ }
143
+ } else {
144
+ consecutive = measurement.matched ? consecutive + 1 : 0;
145
+ result.metrics['连续满足帧数'] = consecutive;
146
+ if (consecutive >= config.consecutive) { result.result = true; break; }
147
+ if (now() - observedAt >= config.durationMs) { result.result = false; break; }
148
+ }
149
+ await input.wait(Math.min(config.intervalMs, Math.max(1, config.durationMs - (now() - observedAt))));
150
+ }
151
+ if (last) keep('判定帧', last);
152
+ result.message = result.result ? '条件成立' : '条件不成立';
153
+ } catch (error) {
154
+ if (input.signal?.aborted) throw error;
155
+ result.message = `无法判定:${error instanceof Error ? error.message : '图像检测异常'}`;
156
+ result.result = null;
157
+ }
158
+ result.durationMs = now() - started;
159
+ return result;
160
+ }
@@ -43,10 +43,19 @@ export async function openGalleryOnDevice(deviceId: string, signal?: AbortSignal
43
43
  }
44
44
  }
45
45
  if (!component) throw new Error('未找到可启动的相册应用,请检查手机是否安装并启用了相册');
46
+ // 仅停止已解析出的目标相册,避免复用后台页面;不清除应用数据。
47
+ const packageName = component.split('/')[0]!;
48
+ try {
49
+ const stopped = await run(['am', 'force-stop', '--user', 'current', packageName]);
50
+ if (/Error:|Exception|Permission Denial/i.test(stopped)) throw new Error(stopped);
51
+ } catch (error) {
52
+ if (signal?.aborted) throw error;
53
+ throw new Error(`停止相册失败(${packageName}):${error instanceof Error ? error.message : String(error)}`);
54
+ }
46
55
  const output = await run(['am', 'start', '-W', '--user', 'current', '-a', MAIN, '-n', component]);
47
56
  // am start 某些错误仍返回 0,不能只检查进程退出码。
48
57
  if (!/^Status:\s*ok\s*$/im.test(output) || /Error:|Exception|Permission Denial/i.test(output)) {
49
58
  throw new Error(`启动相册失败:${output || component}`);
50
59
  }
51
- return `已启动相册:${component}`;
60
+ return `已启动相册:${component}(启动前已停止相册进程)`;
52
61
  }
@@ -5,6 +5,9 @@ import { appDataPath } from '../paths';
5
5
  import { loadConfig } from '../config';
6
6
  import { saveAppiumReplayReport, type AppiumRecordedScriptRecord, type AppiumRecordedStepRecord } from './repository';
7
7
  import { flowBranchLabel } from '../../src/appium-recorder/flow-labels';
8
+ import type { ImageCheckResult } from '../../src/appium-recorder/image-check';
9
+
10
+ type ReportImageCheck = ImageCheckResult & { nodeId: string; nodeNumber: number; nodeLabel: string; scriptName: string; settings: string };
8
11
 
9
12
  export type AppiumReplayFrame = {
10
13
  sequence: number;
@@ -245,6 +248,7 @@ function createReplayHtml(input: {
245
248
  completedAt: Date;
246
249
  frames: AppiumReplayFrame[];
247
250
  visualChecks: AppiumReplayVisualCheck[];
251
+ imageChecks?: ReportImageCheck[];
248
252
  output: string;
249
253
  }) {
250
254
  const imageCache = new Map<string, string>();
@@ -266,6 +270,9 @@ function createReplayHtml(input: {
266
270
  diffBase64: undefined,
267
271
  })),
268
272
  output: input.output,
273
+ imageChecks: (input.imageChecks || []).map(check => ({ ...check,
274
+ images: check.images.map(image => ({ label: image.label, url: htmlImageUrl(image.base64, imageCache) })),
275
+ })),
269
276
  });
270
277
  const resultClass = input.resultText === '成功' ? 'success' : input.resultText === '已终止' ? 'stopped' : 'failed';
271
278
  return `<!doctype html>
@@ -387,6 +394,7 @@ function createReplayHtml(input: {
387
394
  </dl>
388
395
  </section>
389
396
  <nav id="step-list" class="step-list" aria-label="执行步骤"></nav>
397
+ <details open><summary>图像判断</summary><div id="image-checks" class="visual-check-list"></div></details>
390
398
  <details><summary>视觉变化检测</summary><div id="visual-checks" class="visual-check-list"></div></details>
391
399
  <details><summary>完整回放日志</summary><pre id="log"></pre></details>
392
400
  </aside>
@@ -587,6 +595,37 @@ function createReplayHtml(input: {
587
595
  thumbnail.addEventListener('click', (event) => { event.stopPropagation(); pause(); seekTo(frame.offsetMs); });
588
596
  elements['timeline-track'].append(thumbnail);
589
597
  });
598
+ const imageChecksElement = document.querySelector('#image-checks');
599
+ if (!(data.imageChecks || []).length) imageChecksElement.parentElement.hidden = true;
600
+ (data.imageChecks || []).forEach(check => {
601
+ const article = document.createElement('article');
602
+ article.className = 'visual-check';
603
+ const title = document.createElement('h3');
604
+ title.textContent = check.scriptName + ' · ' + check.nodeNumber + '. ' + check.nodeLabel;
605
+ article.append(title);
606
+ const detail = document.createElement('dl');
607
+ const region = check.region || {};
608
+ const entries = [['结果', check.result === null ? '无法判定' : String(check.result)], ['配置', check.settings],
609
+ ['区域', [region.x, region.y, region.width, region.height].join(', ')],
610
+ ['采样', check.sampleCount + ' 帧'], ['耗时', check.durationMs + 'ms'], ['说明', check.message], ...Object.entries(check.metrics)];
611
+ entries.forEach(([key, value]) => {
612
+ const dt = document.createElement('dt'), dd = document.createElement('dd');
613
+ dt.textContent = key; dd.textContent = String(value); detail.append(dt, dd);
614
+ });
615
+ article.append(detail);
616
+ const images = document.createElement('div'); images.className = 'visual-images';
617
+ check.images.forEach(image => {
618
+ if (!image.url) return;
619
+ const figure = document.createElement('div'); figure.className = 'visual-image';
620
+ const caption = document.createElement('span'); caption.textContent = image.label;
621
+ const img = document.createElement('img'); img.src = image.url; img.alt = image.label; img.title = '点击放大'; img.tabIndex = 0;
622
+ const open = () => openImagePreview(image.url, check.nodeLabel + ' · ' + image.label);
623
+ img.addEventListener('click', open);
624
+ img.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); open(); } });
625
+ figure.append(caption, img); images.append(figure);
626
+ });
627
+ article.append(images); imageChecksElement.append(article);
628
+ });
590
629
  if (!visualChecks.length) {
591
630
  elements['visual-checks'].textContent = '本次没有视觉变化检测节点';
592
631
  }
@@ -689,6 +728,7 @@ export async function createAppiumReplayReport(input: {
689
728
  completedAt: Date;
690
729
  frames?: AppiumReplayFrame[];
691
730
  visualChecks?: AppiumReplayVisualCheck[];
731
+ imageChecks?: ReportImageCheck[];
692
732
  }) {
693
733
  const configuredOutputPath = loadConfig().runtime.reportOutputPath.trim();
694
734
  const outputDir = appDataPath(configuredOutputPath || 'output');
@@ -721,6 +761,7 @@ export async function createAppiumReplayReport(input: {
721
761
  completedAt: input.completedAt,
722
762
  frames: input.frames || [],
723
763
  visualChecks,
764
+ imageChecks: input.imageChecks,
724
765
  output: persistedOutput,
725
766
  });
726
767
  const markdown = [
@@ -10,8 +10,10 @@ import { normalizeLegacyNestedConditionBranches } from '../../src/appium-recorde
10
10
  import { validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
11
11
  import { validateVariables, validateExtraction, validateReturns, type TestVariable, type VariableExtraction, type ScriptReturn } from '../../src/appium-recorder/variables';
12
12
  import { getPresetVariables, savePresetVariables, deleteScriptVariables } from './variable-store';
13
+ import { validateImageCheck, type ImageCheckConfig } from '../../src/appium-recorder/image-check';
13
14
 
14
15
  export type AppiumRecordedStepRecord = {
16
+ mergeUndo?: import('../../src/appium-recorder/types').BranchMergeUndo;
15
17
  id: string;
16
18
  type:
17
19
  | 'tap'
@@ -26,6 +28,7 @@ export type AppiumRecordedStepRecord = {
26
28
  | 'checkedState'
27
29
  | 'radioButtonState'
28
30
  | 'aiRecognition'
31
+ | 'imageCheck'
29
32
  | 'textClick'
30
33
  | 'key'
31
34
  | 'waitActivity'
@@ -89,6 +92,7 @@ export type AppiumRecordedStepRecord = {
89
92
  };
90
93
  value?: string;
91
94
  logPrefix?: string;
95
+ imageCheck?: ImageCheckConfig;
92
96
  extractVariable?: VariableExtraction;
93
97
  parameters?: TestVariable[];
94
98
  returns?: ScriptReturn[];
@@ -369,6 +373,8 @@ export function saveAppiumRecordedScript(input: {
369
373
  const steps = normalizeLegacyNestedConditionBranches(input.steps || []);
370
374
  if (input.variables !== undefined) validateVariables(input.variables);
371
375
  for (const step of steps) {
376
+ // 读取旧脚本必须容错,让用户能打开并修正无效配置;仅在写入时校验。
377
+ if (step.type === 'imageCheck') validateImageCheck(step.imageCheck);
372
378
  if (step.type === 'extractVariable') validateExtraction(step.extractVariable);
373
379
  if (step.type === 'runScript') { validateVariables(step.parameters); validateReturns(step.returns); }
374
380
  }
@@ -3,6 +3,10 @@ import { getPresetVariables, savePresetVariables } from './variable-store';
3
3
  import type { TestVariable } from '../../src/appium-recorder/variables';
4
4
  import { deleteRunHistory, getRunHistory, listRunHistory, saveRunHistory } from './run-history';
5
5
  import { recognizeDeviceScreen } from './ai-recognition';
6
+ import { readImage, cropImage } from './image-check';
7
+ import { PNG } from 'pngjs';
8
+ import { adbScreenshotBase64 } from './screenshot';
9
+ import type { AppiumVisualChangeRegion } from '../../src/appium-recorder/types';
6
10
  import { readFreshWindowHierarchy } from './tree-dump';
7
11
  import type { IncomingMessage, ServerResponse } from 'node:http';
8
12
  import {
@@ -180,6 +184,21 @@ export async function handleAppiumRecorderRequest(
180
184
  return true;
181
185
  }
182
186
 
187
+ if (pathname === '/api/appium-recorder/image-check/capture' && req.method === 'POST') {
188
+ const parsed = await readBody<{ deviceId: string; region: AppiumVisualChangeRegion; screenWidth: number; screenHeight: number }>(req);
189
+ const deviceId = parsed.deviceId?.trim() || selectedDeviceId;
190
+ if (!deviceId) throw new Error('请选择设备');
191
+ assertDeviceAllowed(deviceId);
192
+ if (isRemoteDeviceId(deviceId)) throw new Error('远程设备暂不支持图像模板采集');
193
+ if (replayingDevices.has(deviceId)) { sendJson(res, { message: '设备正在回放,不能采集模板' }, 409); return true; }
194
+ const source = readImage(Buffer.from(await adbScreenshotBase64(deviceId), 'base64'));
195
+ if (source.width !== parsed.screenWidth || source.height !== parsed.screenHeight) throw new Error('设备屏幕尺寸已变化,请刷新预览后重新框选');
196
+ const base64 = PNG.sync.write(cropImage(source, parsed.region)).toString('base64');
197
+ if (base64.length > 4 * 1024 * 1024) throw new Error('模板区域过大,请缩小到目标图标');
198
+ sendJson(res, { base64, screenWidth: source.width, screenHeight: source.height });
199
+ return true;
200
+ }
201
+
183
202
  if (pathname === '/api/appium-recorder/ai-recognition/test' && req.method === 'POST') {
184
203
  const parsed = await readBody<{ deviceId?: string; prompt?: unknown; timeoutMs?: number }>(req);
185
204
  const deviceId = parsed.deviceId?.trim() || selectedDeviceId;
@@ -45,7 +45,7 @@ export async function createAppiumScriptExport(
45
45
  appActivity: script.appActivity,
46
46
  deviceId: script.deviceId,
47
47
  variables: clearSecrets(script.variables),
48
- steps: script.steps.map(step => ({ ...step, ...(step.parameters ? { parameters: clearSecrets(step.parameters) } : {}) })),
48
+ steps: script.steps.map(step => ({ ...step, mergeUndo: undefined, ...(step.parameters ? { parameters: clearSecrets(step.parameters) } : {}) })),
49
49
  },
50
50
  }), null, 2);
51
51
  };