android-midscene-automation 0.1.33 → 0.1.35
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 +11 -0
- package/README.md +11 -0
- package/package.json +2 -1
- package/server/appium-recorder/appium-runner.ts +39 -1
- package/server/appium-recorder/image-check.ts +160 -0
- package/server/appium-recorder/report.ts +41 -0
- package/server/appium-recorder/repository.ts +5 -0
- package/server/appium-recorder/routes.ts +19 -0
- package/src/appium-recorder/AppiumPage.vue +86 -4
- package/src/appium-recorder/action-descriptions.ts +46 -0
- package/src/appium-recorder/api.ts +5 -0
- package/src/appium-recorder/components/ComponentInspector.vue +9 -2
- package/src/appium-recorder/components/FlowActionMenu.vue +26 -5
- package/src/appium-recorder/components/FlowCanvas.vue +5 -4
- package/src/appium-recorder/components/FlowNodeCard.vue +5 -3
- package/src/appium-recorder/components/ImageCheckDialog.vue +143 -0
- package/src/appium-recorder/components/NestedConditionBranches.vue +6 -2
- package/src/appium-recorder/components/RecordedSteps.vue +7 -6
- package/src/appium-recorder/flow-copy.ts +4 -0
- package/src/appium-recorder/flow-graph.ts +43 -19
- package/src/appium-recorder/flow-insert.ts +27 -0
- package/src/appium-recorder/flow-labels.ts +4 -1
- package/src/appium-recorder/image-check.ts +117 -0
- package/src/appium-recorder/types.ts +3 -0
- package/src/appium-recorder/visual-change-pairs.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
# 更新记录
|
|
2
|
+
## v0.1.35
|
|
3
|
+
- 修复已有图像判断配置无效时导致整个脚本列表加载失败的问题,读取保留原数据,保存和执行时再校验。
|
|
4
|
+
- 图像判断配置显示模板尺寸,提前提示模板超出检测区域,并支持手动一键扩大框选区域;回放尺寸不匹配时展示具体尺寸,避免仅在运行时得到笼统错误。
|
|
5
|
+
- 新增“图像判断”分支节点,支持本地 OpenCV 模板匹配、图片勾选双模板、黑屏、区域颜色和多帧变化检测;支持组件定位、框选区域、模板采集与上传、连续采样及 true/false 分支,证据不足时明确报告无法判定。HTML 报告展示指标和可放大的检测图片,并沿用敏感运行截图保护。
|
|
6
|
+
- 合流后的公共节点上方新增插入入口,支持新增和粘贴操作,所有汇入分支先执行新操作再进入原公共节点。
|
|
7
|
+
- 修复嵌套判断显式合流到外层公共节点时连接被过滤、公共节点提前排列的问题,支持跨层分支合流。
|
|
8
|
+
|
|
9
|
+
## v0.1.34
|
|
10
|
+
- 修复同一分支内显式跳转被画布误画为顺序连接的问题,失效跳转目标不再错误连接到相邻节点。
|
|
11
|
+
- 修复判断分支默认返回公共后续节点时画布漏连线、节点重叠的问题,嵌套分支显示与回放路径保持一致。
|
|
12
|
+
|
|
2
13
|
## v0.1.33
|
|
3
14
|
- 操作菜单移除“断言存在”和“断言文本”,统一保留“判断存在”及其文本匹配配置;旧脚本中的断言节点仍兼容原有执行行为。
|
|
4
15
|
- “点击坐标”节点支持编辑 X、Y 坐标,保存后回放使用新坐标;默认节点名称同步更新,自定义名称保持不变。
|
package/README.md
CHANGED
|
@@ -1229,6 +1229,17 @@ Linux: ~/.local/share/android-midscene-automation
|
|
|
1229
1229
|
|
|
1230
1230
|
# 项目更新记录
|
|
1231
1231
|
|
|
1232
|
+
## v0.1.35
|
|
1233
|
+
- 修复已有图像判断配置无效时导致整个脚本列表加载失败的问题,读取保留原数据,保存和执行时再校验。
|
|
1234
|
+
- 图像判断配置显示模板尺寸,提前提示模板超出检测区域,并支持手动一键扩大框选区域;回放尺寸不匹配时展示具体尺寸,避免仅在运行时得到笼统错误。
|
|
1235
|
+
- 新增“图像判断”分支节点,支持本地 OpenCV 模板匹配、图片勾选双模板、黑屏、区域颜色和多帧变化检测;支持组件定位、框选区域、模板采集与上传、连续采样及 true/false 分支,证据不足时明确报告无法判定。HTML 报告展示指标和可放大的检测图片,并沿用敏感运行截图保护。
|
|
1236
|
+
- 合流后的公共节点上方新增插入入口,支持新增和粘贴操作,所有汇入分支先执行新操作再进入原公共节点。
|
|
1237
|
+
- 修复嵌套判断显式合流到外层公共节点时连接被过滤、公共节点提前排列的问题,支持跨层分支合流。
|
|
1238
|
+
|
|
1239
|
+
## v0.1.34
|
|
1240
|
+
- 修复同一分支内显式跳转被画布误画为顺序连接的问题,失效跳转目标不再错误连接到相邻节点。
|
|
1241
|
+
- 修复判断分支默认返回公共后续节点时画布漏连线、节点重叠的问题,嵌套分支显示与回放路径保持一致。
|
|
1242
|
+
|
|
1232
1243
|
## v0.1.33
|
|
1233
1244
|
- 操作菜单移除“断言存在”和“断言文本”,统一保留“判断存在”及其文本匹配配置;旧脚本中的断言节点仍兼容原有执行行为。
|
|
1234
1245
|
- “点击坐标”节点支持编辑 X、Y 坐标,保存后回放使用新坐标;默认节点名称同步更新,自定义名称保持不变。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "android-midscene-automation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.35",
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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,6 +10,7 @@ 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 = {
|
|
15
16
|
id: string;
|
|
@@ -26,6 +27,7 @@ export type AppiumRecordedStepRecord = {
|
|
|
26
27
|
| 'checkedState'
|
|
27
28
|
| 'radioButtonState'
|
|
28
29
|
| 'aiRecognition'
|
|
30
|
+
| 'imageCheck'
|
|
29
31
|
| 'textClick'
|
|
30
32
|
| 'key'
|
|
31
33
|
| 'waitActivity'
|
|
@@ -89,6 +91,7 @@ export type AppiumRecordedStepRecord = {
|
|
|
89
91
|
};
|
|
90
92
|
value?: string;
|
|
91
93
|
logPrefix?: string;
|
|
94
|
+
imageCheck?: ImageCheckConfig;
|
|
92
95
|
extractVariable?: VariableExtraction;
|
|
93
96
|
parameters?: TestVariable[];
|
|
94
97
|
returns?: ScriptReturn[];
|
|
@@ -369,6 +372,8 @@ export function saveAppiumRecordedScript(input: {
|
|
|
369
372
|
const steps = normalizeLegacyNestedConditionBranches(input.steps || []);
|
|
370
373
|
if (input.variables !== undefined) validateVariables(input.variables);
|
|
371
374
|
for (const step of steps) {
|
|
375
|
+
// 读取旧脚本必须容错,让用户能打开并修正无效配置;仅在写入时校验。
|
|
376
|
+
if (step.type === 'imageCheck') validateImageCheck(step.imageCheck);
|
|
372
377
|
if (step.type === 'extractVariable') validateExtraction(step.extractVariable);
|
|
373
378
|
if (step.type === 'runScript') { validateVariables(step.parameters); validateReturns(step.returns); }
|
|
374
379
|
}
|
|
@@ -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;
|