android-midscene-automation 0.1.38 → 0.1.39
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 +8 -0
- package/README.md +8 -0
- package/package.json +12 -7
- package/server/appium-recorder/appium-runner.ts +50 -16
- package/server/appium-recorder/image-check.ts +10 -0
- package/server/appium-recorder/replay-mp4.ts +72 -0
- package/server/appium-recorder/replay-video.ts +99 -0
- package/server/appium-recorder/report.ts +60 -6
- package/server/appium-recorder/routes.ts +13 -17
- package/server/appium-recorder/run-history.ts +7 -1
- package/server/appium-recorder/scrcpy-recording-source.ts +80 -0
- package/server/appium-recorder/video-response.ts +36 -0
- package/src/appium-recorder/AppiumPage.vue +24 -41
- package/src/appium-recorder/RunHistoryDialog.vue +7 -2
- package/src/appium-recorder/action-descriptions.ts +1 -1
- package/src/appium-recorder/api.ts +3 -10
- package/src/appium-recorder/components/FlowNodeCard.vue +2 -3
- package/src/appium-recorder/components/FlowStepEditor.vue +0 -6
- package/src/appium-recorder/components/ImageCheckDialog.vue +28 -5
- package/src/appium-recorder/components/NestedConditionBranches.vue +0 -1
- package/src/appium-recorder/components/RecordedSteps.vue +0 -1
- package/src/appium-recorder/flow-graph.ts +2 -2
- package/src/appium-recorder/flow-labels.ts +2 -2
- package/src/appium-recorder/image-check.ts +3 -1
- package/src/appium-recorder/run-history.ts +1 -0
- package/src/components/config/AppiumConfigPanel.vue +0 -35
- package/src/appium-recorder/components/AiRecognitionTestDialog.vue +0 -71
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { sendReplayVideo } from './video-response';
|
|
2
3
|
import { getPresetVariables, savePresetVariables } from './variable-store';
|
|
3
4
|
import type { TestVariable } from '../../src/appium-recorder/variables';
|
|
4
5
|
import { deleteRunHistory, getRunHistory, listRunHistory, saveRunHistory } from './run-history';
|
|
5
|
-
import { recognizeDeviceScreen } from './ai-recognition';
|
|
6
6
|
import { readImage, cropImage } from './image-check';
|
|
7
7
|
import { PNG } from 'pngjs';
|
|
8
8
|
import { adbScreenshotBase64 } from './screenshot';
|
|
@@ -200,20 +200,7 @@ export async function handleAppiumRecorderRequest(
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
if (pathname === '/api/appium-recorder/ai-recognition/test' && req.method === 'POST') {
|
|
203
|
-
|
|
204
|
-
const deviceId = parsed.deviceId?.trim() || selectedDeviceId;
|
|
205
|
-
if (!deviceId) throw new Error('请选择设备');
|
|
206
|
-
assertDeviceAllowed(deviceId);
|
|
207
|
-
if (isRemoteDeviceId(deviceId)) throw new Error('远程设备暂不支持 AI 识别测试');
|
|
208
|
-
if (replayingDevices.has(deviceId)) { sendJson(res, { message: '设备正在回放,请稍后测试' }, 409); return true; }
|
|
209
|
-
const controller = new AbortController();
|
|
210
|
-
const abort = () => controller.abort();
|
|
211
|
-
res.once('close', abort);
|
|
212
|
-
try {
|
|
213
|
-
sendJson(res, await recognizeDeviceScreen({ deviceId, prompt: parsed.prompt, timeoutMs: parsed.timeoutMs, signal: controller.signal }));
|
|
214
|
-
} finally {
|
|
215
|
-
res.off('close', abort);
|
|
216
|
-
}
|
|
203
|
+
sendJson(res, { message: 'AI 识别操作已移除,请使用图像判断或原生组件判断' }, 410);
|
|
217
204
|
return true;
|
|
218
205
|
}
|
|
219
206
|
|
|
@@ -345,6 +332,14 @@ export async function handleAppiumRecorderRequest(
|
|
|
345
332
|
return true;
|
|
346
333
|
}
|
|
347
334
|
|
|
335
|
+
const videoMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/history\/([^/]+)\/video$/);
|
|
336
|
+
if (videoMatch && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
337
|
+
const run = getRunHistory(decodeURIComponent(videoMatch[1]), decodeURIComponent(videoMatch[2]));
|
|
338
|
+
if (!run?.video) { sendJson(res, { error: '本次运行没有视频' }, 404); return true; }
|
|
339
|
+
assertDeviceAllowed(run.deviceId);
|
|
340
|
+
await sendReplayVideo(req, res, run.video.filePath);
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
348
343
|
const historyMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/history(?:\/([^/]+))?$/);
|
|
349
344
|
if (historyMatch) {
|
|
350
345
|
const scriptId = decodeURIComponent(historyMatch[1]);
|
|
@@ -362,12 +357,13 @@ export async function handleAppiumRecorderRequest(
|
|
|
362
357
|
}
|
|
363
358
|
const replayMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/replay$/);
|
|
364
359
|
if (replayMatch && req.method === 'POST') {
|
|
365
|
-
const parsed = await readBody<{ deviceId?: string; parameters?: TestVariable[] }>(req);
|
|
360
|
+
const parsed = await readBody<{ deviceId?: string; parameters?: TestVariable[]; recordVideo?: boolean }>(req);
|
|
366
361
|
const script = getAppiumRecordedScript(decodeURIComponent(replayMatch[1]));
|
|
367
362
|
if (!script) throw new Error('Appium 录制脚本不存在');
|
|
368
363
|
const deviceId = parsed.deviceId || selectedDeviceId;
|
|
369
364
|
assertDeviceAllowed(deviceId);
|
|
370
365
|
const streamOutput = req.headers.accept?.includes('application/x-ndjson') === true;
|
|
366
|
+
if (parsed.recordVideo === true && isRemoteDeviceId(deviceId)) throw new Error('远程设备暂不支持回放录屏,请在连接设备的主机本地运行');
|
|
371
367
|
if (streamOutput) {
|
|
372
368
|
res.statusCode = 200;
|
|
373
369
|
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
@@ -403,7 +399,7 @@ export async function handleAppiumRecorderRequest(
|
|
|
403
399
|
deviceId,
|
|
404
400
|
streamOutput ? (line) => sendStreamEvent(res, { type: 'log', line }) : undefined,
|
|
405
401
|
replayAbortController.signal,
|
|
406
|
-
{ parameters: parsed.parameters, globalVariables: getPresetVariables() },
|
|
402
|
+
{ parameters: parsed.parameters, globalVariables: getPresetVariables(), recordVideo: parsed.recordVideo === true },
|
|
407
403
|
);
|
|
408
404
|
try { saveRunHistory(result.history); }
|
|
409
405
|
catch { result.output += '\n历史记录保存失败'; }
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createId, querySql, runSql, sqlJson, sqlString } from '../storage/sqlite';
|
|
2
|
+
import { rmSync } from 'node:fs';
|
|
3
|
+
import { basename } from 'node:path';
|
|
2
4
|
import type { RunDetail, RunSummary } from '../../src/appium-recorder/run-history';
|
|
3
5
|
|
|
4
6
|
function ensureTable() {
|
|
@@ -29,6 +31,10 @@ export function getRunHistory(scriptId: string, id: string): RunDetail | null {
|
|
|
29
31
|
|
|
30
32
|
export function deleteRunHistory(scriptId: string, id: string) {
|
|
31
33
|
ensureTable();
|
|
32
|
-
|
|
34
|
+
const video = getRunHistory(scriptId, id)?.video;
|
|
35
|
+
// 只清理服务端生成并关联到该记录的录像,不接受客户端文件路径。
|
|
36
|
+
if (video && basename(video.filePath) === video.fileName && /^replay-[\d]+-[\da-f-]+\.mp4$/.test(video.fileName)) {
|
|
37
|
+
rmSync(video.filePath, { force: true });
|
|
38
|
+
}
|
|
33
39
|
runSql(`PRAGMA secure_delete=ON; DELETE FROM appium_run_history WHERE script_id=${sqlString(scriptId)} AND id=${sqlString(id)};`);
|
|
34
40
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { AdbServerClient, type Adb } from '@yume-chan/adb';
|
|
7
|
+
import { AdbServerNodeTcpConnector } from '@yume-chan/adb-server-node-tcp';
|
|
8
|
+
import { AdbScrcpyClient, AdbScrcpyOptions3_3_3 } from '@yume-chan/adb-scrcpy';
|
|
9
|
+
import { ScrcpyVideoCodecId } from '@yume-chan/scrcpy';
|
|
10
|
+
import { getAdbCommand } from '../android-sdk';
|
|
11
|
+
|
|
12
|
+
export function waitForVideoTask<T>(task: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const abort = () => reject(signal.reason);
|
|
15
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
16
|
+
task.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
17
|
+
if (signal.aborted) abort();
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function adbCommand(args: string[], signal?: AbortSignal) {
|
|
22
|
+
return new Promise<void>((resolve, reject) => {
|
|
23
|
+
execFile(getAdbCommand(), args, { timeout: 10000, signal, windowsHide: true }, error => error ? reject(error) : resolve());
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function openRecordingSource(deviceId: string, signal: AbortSignal) {
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const bin = join(dirname(require.resolve('@midscene/android-playground/package.json')), 'bin');
|
|
30
|
+
const version = (await readFile(join(bin, 'scrcpy-server.version'), 'utf8')).trim();
|
|
31
|
+
if (version !== 'v3.3.3') throw new Error(`内置 scrcpy 服务端版本不兼容:${version}`);
|
|
32
|
+
const remotePath = `/data/local/tmp/midscene-recording-${randomUUID()}.jar`;
|
|
33
|
+
let adb: Adb | undefined;
|
|
34
|
+
let client: Awaited<ReturnType<typeof AdbScrcpyClient.start>> | undefined;
|
|
35
|
+
let closing = false;
|
|
36
|
+
let logs = '';
|
|
37
|
+
let closeTask: Promise<void> | undefined;
|
|
38
|
+
const close = () => closeTask ??= (async () => {
|
|
39
|
+
closing = true;
|
|
40
|
+
await waitForVideoTask(Promise.allSettled([client?.close(), adb?.close()]), AbortSignal.timeout(3000)).catch(() => undefined);
|
|
41
|
+
await adbCommand(['-s', deviceId, 'shell', 'rm', '-f', remotePath]).catch(() => undefined);
|
|
42
|
+
})();
|
|
43
|
+
try {
|
|
44
|
+
await adbCommand(['start-server'], signal);
|
|
45
|
+
await adbCommand(['-s', deviceId, 'push', join(bin, 'scrcpy-server'), remotePath], signal);
|
|
46
|
+
const server = new AdbServerClient(new AdbServerNodeTcpConnector({ host: '127.0.0.1', port: 5037 }));
|
|
47
|
+
adb = await waitForVideoTask(server.createAdb({ serial: deviceId }).then(connection => {
|
|
48
|
+
if (closing || signal.aborted) { void connection.close(); throw signal.reason || new Error('录屏已取消'); }
|
|
49
|
+
return connection;
|
|
50
|
+
}), signal);
|
|
51
|
+
// 独立 socket ID 和上传路径,停止录制不会关闭正在使用的设备预览。
|
|
52
|
+
const options = new AdbScrcpyOptions3_3_3({
|
|
53
|
+
scid: Math.floor(Math.random() * 0x7fffffff).toString(16).padStart(8, '0'),
|
|
54
|
+
audio: false, control: false, videoCodec: 'h264', sendFrameMeta: true,
|
|
55
|
+
maxSize: 1280, maxFps: 15, videoBitRate: 2000000,
|
|
56
|
+
captureOrientation: '@', videoCodecOptions: 'profile=1,max-bframes=0,i-frame-interval=2',
|
|
57
|
+
});
|
|
58
|
+
client = await waitForVideoTask(AdbScrcpyClient.start(adb, remotePath, options).then(value => {
|
|
59
|
+
if (closing || signal.aborted) { void value.close(); throw signal.reason || new Error('录屏已取消'); }
|
|
60
|
+
return value;
|
|
61
|
+
}), signal);
|
|
62
|
+
// 持续消费服务端输出,避免输出管道阻塞编码进程。
|
|
63
|
+
const outputReader = client.output.getReader();
|
|
64
|
+
void (async () => {
|
|
65
|
+
try {
|
|
66
|
+
while (true) {
|
|
67
|
+
const { done, value } = await outputReader.read();
|
|
68
|
+
if (done) break;
|
|
69
|
+
logs = `${logs}\n${value}`.slice(-4000);
|
|
70
|
+
}
|
|
71
|
+
} finally { outputReader.releaseLock(); }
|
|
72
|
+
})().catch(() => undefined);
|
|
73
|
+
const video = await waitForVideoTask(client.videoStream!, signal);
|
|
74
|
+
if (video.metadata.codec !== ScrcpyVideoCodecId.H264) throw new Error('录屏需要 H.264 视频流');
|
|
75
|
+
return { stream: video.stream, close, diagnostics: () => logs };
|
|
76
|
+
} catch (error) {
|
|
77
|
+
await close();
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
|
|
4
|
+
// 浏览器按字节加载 MP4;支持 Range 才能直接跳转到录像中间。
|
|
5
|
+
export async function sendReplayVideo(req: IncomingMessage, res: ServerResponse, path: string) {
|
|
6
|
+
const file = await open(path, 'r').catch(() => null);
|
|
7
|
+
if (!file) { res.statusCode = 404; res.end('视频文件不存在'); return; }
|
|
8
|
+
let streaming = false;
|
|
9
|
+
try {
|
|
10
|
+
const { size } = await file.stat();
|
|
11
|
+
let start = 0, end = size - 1;
|
|
12
|
+
const range = req.headers.range;
|
|
13
|
+
if (range) {
|
|
14
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
15
|
+
if (match && (match[1] || match[2])) {
|
|
16
|
+
start = match[1] ? Number(match[1]) : Math.max(0, size - Number(match[2]));
|
|
17
|
+
end = match[1] && match[2] ? Math.min(Number(match[2]), size - 1) : size - 1;
|
|
18
|
+
} else start = size;
|
|
19
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= size) {
|
|
20
|
+
res.writeHead(416, { 'Content-Range': `bytes */${size}` }); res.end(); return;
|
|
21
|
+
}
|
|
22
|
+
res.statusCode = 206;
|
|
23
|
+
res.setHeader('Content-Range', `bytes ${start}-${end}/${size}`);
|
|
24
|
+
}
|
|
25
|
+
res.setHeader('Content-Type', 'video/mp4');
|
|
26
|
+
res.setHeader('Accept-Ranges', 'bytes');
|
|
27
|
+
res.setHeader('Cache-Control', 'private, no-store');
|
|
28
|
+
res.setHeader('Content-Length', String(Math.max(0, end - start + 1)));
|
|
29
|
+
if (req.method === 'HEAD' || !size) { res.end(); return; }
|
|
30
|
+
const stream = file.createReadStream({ start, end });
|
|
31
|
+
streaming = true;
|
|
32
|
+
res.once('close', () => stream.destroy());
|
|
33
|
+
stream.once('error', () => res.destroy());
|
|
34
|
+
stream.pipe(res);
|
|
35
|
+
} finally { if (!streaming) await file.close(); }
|
|
36
|
+
}
|
|
@@ -3,7 +3,7 @@ import { computed, h, onMounted, onUnmounted, provide, reactive, shallowRef, wat
|
|
|
3
3
|
import { flowBackgroundKey, normalizeFlowBackground } from './flow-appearance';
|
|
4
4
|
import { DEFAULT_NODE_TIMEOUT_MS } from './node-timeout';
|
|
5
5
|
import { ElForm, ElFormItem, ElInputNumber, ElMessage, ElMessageBox, ElOption, ElSelect } from 'element-plus';
|
|
6
|
-
import { Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Plus, Refresh, Upload, VideoPlay, View, Clock } from '@element-plus/icons-vue';
|
|
6
|
+
import { ArrowDown, Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Plus, Refresh, Upload, VideoPlay, View, Clock } from '@element-plus/icons-vue';
|
|
7
7
|
import RunHistoryDialog from './RunHistoryDialog.vue';
|
|
8
8
|
import type { AndroidDevice, AppPreset, DeviceAction } from '../types';
|
|
9
9
|
import DevicePreviewPanel from '../components/device/DevicePreviewPanel.vue';
|
|
@@ -43,8 +43,6 @@ import NewScriptDialog from './components/NewScriptDialog.vue';
|
|
|
43
43
|
import VisualChangeDialog from './components/VisualChangeDialog.vue';
|
|
44
44
|
import ImageCheckDialog from './components/ImageCheckDialog.vue';
|
|
45
45
|
import { createImageCheckConfig, validateImageCheck, type ImageCheckConfig } from './image-check';
|
|
46
|
-
import AiRecognitionTestDialog from './components/AiRecognitionTestDialog.vue';
|
|
47
|
-
import { validateAiRecognitionPrompt } from './ai-recognition';
|
|
48
46
|
import {
|
|
49
47
|
createFlowClipboard,
|
|
50
48
|
pasteFlowClipboard,
|
|
@@ -98,6 +96,7 @@ const AUTO_TREE_REFRESH_INTERVAL_MS = 1800;
|
|
|
98
96
|
|
|
99
97
|
const readonlyFlowActionGroups: FlowActionGroup[] = [];
|
|
100
98
|
const readonlyFlowSelectedIndexes: number[] = [];
|
|
99
|
+
const recordReplayVideo = shallowRef(false);
|
|
101
100
|
|
|
102
101
|
const props = defineProps<{
|
|
103
102
|
aiRecognitionModelConfigured?: boolean;
|
|
@@ -137,7 +136,6 @@ const newScriptRevision = shallowRef(0);
|
|
|
137
136
|
const storedWorkbenchTab = window.localStorage.getItem(WORKBENCH_TAB_STORAGE_KEY);
|
|
138
137
|
const activeWorkbenchTab = shallowRef<'recording' | 'scripts' | 'variables'>(storedWorkbenchTab === 'scripts' ? 'scripts' : storedWorkbenchTab === 'variables' ? 'variables' : 'recording');
|
|
139
138
|
const steps = shallowRef<AppiumRecordedStep[]>([]);
|
|
140
|
-
const aiRecognitionTestStep = shallowRef<AppiumRecordedStep | null>(null);
|
|
141
139
|
const flowClipboard = shallowRef<FlowClipboard | null>(null);
|
|
142
140
|
const rawXml = shallowRef('');
|
|
143
141
|
const currentActivity = shallowRef('');
|
|
@@ -888,11 +886,6 @@ async function refreshTree() {
|
|
|
888
886
|
|
|
889
887
|
async function executeFlowStep(index: number) {
|
|
890
888
|
const step = steps.value[index];
|
|
891
|
-
if (step?.type === 'aiRecognition') {
|
|
892
|
-
if (replaying.value || recordingBusy.value) { ElMessage.warning('设备正在执行操作,请稍后测试'); return; }
|
|
893
|
-
aiRecognitionTestStep.value = { ...step };
|
|
894
|
-
return;
|
|
895
|
-
}
|
|
896
889
|
if (!step || (step.type !== 'launchApp' && step.type !== 'clearAppData')) return;
|
|
897
890
|
if (launchingApp.value) return;
|
|
898
891
|
if (!selectedDeviceId.value) {
|
|
@@ -1391,21 +1384,8 @@ async function addAction(
|
|
|
1391
1384
|
return;
|
|
1392
1385
|
}
|
|
1393
1386
|
if (action === 'aiRecognition') {
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
inputPlaceholder: '例如:检查当前画面有没有显示黑屏',
|
|
1397
|
-
inputValidator: (value) => {
|
|
1398
|
-
try { validateAiRecognitionPrompt(value); return true; }
|
|
1399
|
-
catch (error) { return error instanceof Error ? error.message : '识别内容无效'; }
|
|
1400
|
-
},
|
|
1401
|
-
confirmButtonText: '添加', cancelButtonText: '取消',
|
|
1402
|
-
}).catch(() => null);
|
|
1403
|
-
if (!input) return;
|
|
1404
|
-
return insertStep({
|
|
1405
|
-
id: createStepId(), type: 'aiRecognition', label: 'AI 识别',
|
|
1406
|
-
value: validateAiRecognitionPrompt(input.value), timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
|
|
1407
|
-
flow: { nodeKind: 'condition' },
|
|
1408
|
-
}, index, branchTarget);
|
|
1387
|
+
ElMessage.warning('AI 识别操作已移除,请使用图像判断或原生组件判断');
|
|
1388
|
+
return;
|
|
1409
1389
|
}
|
|
1410
1390
|
if (action === 'textClick') {
|
|
1411
1391
|
const step = reactive<AppiumRecordedStep>({
|
|
@@ -2076,7 +2056,7 @@ async function replayScript() {
|
|
|
2076
2056
|
try {
|
|
2077
2057
|
await pauseAutoTreeRefresh();
|
|
2078
2058
|
const result = await replayAppiumScript(
|
|
2079
|
-
{ id: selectedScript.value.id, deviceId: activeReplayDeviceId.value },
|
|
2059
|
+
{ id: selectedScript.value.id, deviceId: activeReplayDeviceId.value, recordVideo: recordReplayVideo.value },
|
|
2080
2060
|
(line) => {
|
|
2081
2061
|
replayOutput.value += `${replayOutput.value ? '\n' : ''}${line}`;
|
|
2082
2062
|
},
|
|
@@ -2167,15 +2147,23 @@ watch(
|
|
|
2167
2147
|
</el-select>
|
|
2168
2148
|
<el-button :icon="Plus" :disabled="newScriptDisabled || newScriptDialogVisible" @click="requestNewScript">新建</el-button>
|
|
2169
2149
|
<el-button :icon="Check" :loading="saving" @click="saveScript">保存</el-button>
|
|
2170
|
-
<el-button
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2150
|
+
<el-button-group class="replay-button-group">
|
|
2151
|
+
<el-button
|
|
2152
|
+
type="primary"
|
|
2153
|
+
:icon="VideoPlay"
|
|
2154
|
+
:loading="replaying"
|
|
2155
|
+
:disabled="!selectedScript"
|
|
2156
|
+
@click="replayScript"
|
|
2157
|
+
>
|
|
2158
|
+
回放
|
|
2159
|
+
</el-button>
|
|
2160
|
+
<el-popover trigger="click" placement="bottom" :width="260">
|
|
2161
|
+
<template #reference><el-button type="primary" :icon="ArrowDown" :disabled="replaying" aria-label="回放设置" title="回放设置" class="replay-settings-toggle" /></template>
|
|
2162
|
+
<el-tooltip content="使用内置服务端后台录制,无需安装 scrcpy 或 FFmpeg。含敏感变量的运行不录制;分享 HTML 报告时需同时携带 MP4。" placement="bottom" :show-after="300">
|
|
2163
|
+
<el-checkbox v-model="recordReplayVideo" :disabled="replaying">录制回放视频</el-checkbox>
|
|
2164
|
+
</el-tooltip>
|
|
2165
|
+
</el-popover>
|
|
2166
|
+
</el-button-group>
|
|
2179
2167
|
<el-button
|
|
2180
2168
|
v-if="replaying"
|
|
2181
2169
|
type="danger"
|
|
@@ -2383,13 +2371,6 @@ watch(
|
|
|
2383
2371
|
@cancel="newScriptDialogVisible = false"
|
|
2384
2372
|
/>
|
|
2385
2373
|
|
|
2386
|
-
<AiRecognitionTestDialog
|
|
2387
|
-
v-if="aiRecognitionTestStep"
|
|
2388
|
-
:step="aiRecognitionTestStep"
|
|
2389
|
-
:device-id="selectedDeviceId"
|
|
2390
|
-
:model-configured="Boolean(aiRecognitionModelConfigured)"
|
|
2391
|
-
@close="aiRecognitionTestStep = null"
|
|
2392
|
-
/>
|
|
2393
2374
|
|
|
2394
2375
|
<ImageCheckDialog
|
|
2395
2376
|
v-if="imageCheckDraft?.imageCheck"
|
|
@@ -2585,4 +2566,6 @@ watch(
|
|
|
2585
2566
|
.appium-workbench__flow :deep(.appium-recorded-steps-panel) { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
|
2586
2567
|
.appium-workbench__flow :deep(.appium-flow-toolbar) { flex: none; margin-bottom: 8px; }
|
|
2587
2568
|
.appium-workbench__flow :deep(.appium-flow-canvas--vue:not(.appium-flow-canvas--dialog)) { flex: 1; height: auto; min-height: 120px; }
|
|
2569
|
+
.replay-button-group { display: inline-flex; flex-shrink: 0; }
|
|
2570
|
+
.replay-settings-toggle { padding-inline: 8px; }
|
|
2588
2571
|
</style>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, onMounted, ref } from 'vue';
|
|
3
|
-
import { Delete, Refresh } from '@element-plus/icons-vue';
|
|
3
|
+
import { Delete, Refresh, VideoPlay } from '@element-plus/icons-vue';
|
|
4
4
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
5
5
|
import { historyStatistics, type RunSummary, type RunDetail } from './run-history';
|
|
6
6
|
|
|
@@ -16,6 +16,7 @@ const device = ref('');
|
|
|
16
16
|
const error = ref('');
|
|
17
17
|
const nodeKey = ref('');
|
|
18
18
|
const logQuery = ref('');
|
|
19
|
+
const videoRun = ref<RunSummary>();
|
|
19
20
|
const base = `/api/appium-recorder/scripts/${encodeURIComponent(props.scriptId)}/history`;
|
|
20
21
|
const labels = { passed: '通过', failed: '失败', stopped: '已终止' };
|
|
21
22
|
const versions = computed(() => [...new Set(runs.value.map(run => run.appVersion || '未知'))]);
|
|
@@ -49,7 +50,7 @@ async function compare() {
|
|
|
49
50
|
finally { comparing.value = false; }
|
|
50
51
|
}
|
|
51
52
|
async function remove() {
|
|
52
|
-
try { await ElMessageBox.confirm(`删除选中的 ${selected.value.length}
|
|
53
|
+
try { await ElMessageBox.confirm(`删除选中的 ${selected.value.length} 条历史及截图、日志快照和回放视频?原始报告将无法再播放已删除的视频。`, '删除历史结果', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }); }
|
|
53
54
|
catch { return; }
|
|
54
55
|
loading.value = true;
|
|
55
56
|
try {
|
|
@@ -98,6 +99,7 @@ onMounted(load);
|
|
|
98
99
|
<el-table-column prop="deviceId" label="设备" min-width="140" />
|
|
99
100
|
<el-table-column label="结果" width="85"><template #default="{ row }">{{ labels[row.status as keyof typeof labels] }}</template></el-table-column>
|
|
100
101
|
<el-table-column label="耗时" width="100"><template #default="{ row }">{{ seconds(row.durationMs) }}</template></el-table-column>
|
|
102
|
+
<el-table-column label="录像" width="70"><template #default="{ row }"><el-tooltip v-if="row.video" content="播放回放视频"><el-button :icon="VideoPlay" aria-label="播放回放视频" @click="videoRun = row" /></el-tooltip></template></el-table-column>
|
|
101
103
|
</el-table>
|
|
102
104
|
<h3>失败节点分布</h3>
|
|
103
105
|
<el-table :data="stats.failures" max-height="220" empty-text="暂无节点失败记录">
|
|
@@ -129,6 +131,9 @@ onMounted(load);
|
|
|
129
131
|
</div>
|
|
130
132
|
</section>
|
|
131
133
|
</div>
|
|
134
|
+
<el-dialog :model-value="Boolean(videoRun)" title="回放视频" width="min(800px, 90vw)" align-center append-to-body destroy-on-close @close="videoRun = undefined">
|
|
135
|
+
<video v-if="videoRun" :src="`${base}/${encodeURIComponent(videoRun.id)}/video`" controls preload="metadata" style="width:100%;max-height:70vh" />
|
|
136
|
+
</el-dialog>
|
|
132
137
|
</el-dialog>
|
|
133
138
|
</template>
|
|
134
139
|
|
|
@@ -26,7 +26,7 @@ export const actionDescriptions: Record<InsertAction, string> = {
|
|
|
26
26
|
checkboxState: '读取原生 Checkbox 的 checked 状态,按 true/false 分支执行。',
|
|
27
27
|
radioButtonState: '读取原生 RadioButton 的 checked 状态,按 true/false 分支执行。',
|
|
28
28
|
checkedState: '读取 Checkbox、RadioButton 或 Switch 的真实勾选状态,按 true/false 分支执行。不支持仅用图片表示状态的普通 ImageView。',
|
|
29
|
-
aiRecognition: '
|
|
29
|
+
aiRecognition: '此操作已移除,请使用图像判断或原生组件判断。',
|
|
30
30
|
textClick: '在组件树中按指定文字精确或模糊匹配并点击,进入匹配到文字或未匹配到文字分支。不识别图片或自绘画面中的文字。',
|
|
31
31
|
tapIfExists: '找到目标组件时点击,未找到则跳过并继续后续流程。',
|
|
32
32
|
inputIfExists: '找到目标输入组件时写入文字,未找到则跳过。支持 {{变量名}} 引用。',
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { APP_BASE } from '../api';
|
|
2
2
|
import type { AppiumRecordedScript, AppiumRecordedStep } from './types';
|
|
3
|
-
import type { AiRecognitionResult } from './ai-recognition';
|
|
4
3
|
import type { TestVariable } from './variables';
|
|
5
4
|
import type { AppiumVisualChangeRegion } from './types';
|
|
6
5
|
|
|
@@ -73,13 +72,6 @@ export async function getAppiumScripts() {
|
|
|
73
72
|
return readJson<{ scripts: AppiumRecordedScript[] }>(response);
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
export async function testAiRecognition(input: { deviceId: string; prompt: string; timeoutMs?: number }, signal?: AbortSignal) {
|
|
77
|
-
const response = await fetch(`${APP_BASE}/api/appium-recorder/ai-recognition/test`, {
|
|
78
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal,
|
|
79
|
-
});
|
|
80
|
-
return readJson<AiRecognitionResult & { imageBase64: string }>(response);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
75
|
export function saveAppiumScript(input: {
|
|
84
76
|
variables?: import('./variables').TestVariable[];
|
|
85
77
|
id?: string;
|
|
@@ -115,6 +107,7 @@ export async function downloadAppiumScript(id: string) {
|
|
|
115
107
|
}
|
|
116
108
|
|
|
117
109
|
type ReplayResult = {
|
|
110
|
+
videoPath?: string;
|
|
118
111
|
success: boolean;
|
|
119
112
|
stopped?: boolean;
|
|
120
113
|
output: string;
|
|
@@ -131,7 +124,7 @@ type ReplayStreamEvent =
|
|
|
131
124
|
| { type: 'error'; message: string };
|
|
132
125
|
|
|
133
126
|
export async function replayAppiumScript(
|
|
134
|
-
input: { id: string; deviceId?: string; parameters?: import('./variables').TestVariable[] },
|
|
127
|
+
input: { id: string; deviceId?: string; recordVideo?: boolean; parameters?: import('./variables').TestVariable[] },
|
|
135
128
|
onOutput?: (line: string) => void,
|
|
136
129
|
) {
|
|
137
130
|
const response = await fetch(`${APP_BASE}/api/appium-recorder/scripts/${encodeURIComponent(input.id)}/replay`, {
|
|
@@ -140,7 +133,7 @@ export async function replayAppiumScript(
|
|
|
140
133
|
'Content-Type': 'application/json',
|
|
141
134
|
Accept: 'application/x-ndjson',
|
|
142
135
|
},
|
|
143
|
-
body: JSON.stringify({ deviceId: input.deviceId, parameters: input.parameters }),
|
|
136
|
+
body: JSON.stringify({ deviceId: input.deviceId, parameters: input.parameters, recordVideo: input.recordVideo }),
|
|
144
137
|
});
|
|
145
138
|
if (!response.ok || !response.body) return readJson<ReplayResult>(response);
|
|
146
139
|
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue';
|
|
3
3
|
import { Handle, Position } from '@vue-flow/core';
|
|
4
4
|
import { CopyDocument, Delete, Edit, Plus, VideoPlay, View, WarningFilled, Connection, RefreshLeft } from '@element-plus/icons-vue';
|
|
5
|
-
import { AI_MODEL_CONFIG_HINT } from '../ai-recognition';
|
|
6
5
|
import type { AppiumRecordedStep } from '../types';
|
|
7
6
|
import {
|
|
8
7
|
PASTE_COMMAND,
|
|
@@ -176,8 +175,8 @@ watch(() => props.data, () => {
|
|
|
176
175
|
@click="handleStepClick"
|
|
177
176
|
>
|
|
178
177
|
<span class="appium-flow-step-card__content">
|
|
179
|
-
<el-tooltip v-if="data.missingAiModel"
|
|
180
|
-
<el-icon class="appium-ai-model-warning" color="var(--el-color-danger)" role="img" aria-label="AI
|
|
178
|
+
<el-tooltip v-if="data.missingAiModel" content="AI 识别操作已移除,请删除此节点并改用图像判断或原生组件判断" placement="top">
|
|
179
|
+
<el-icon class="appium-ai-model-warning" color="var(--el-color-danger)" role="img" aria-label="AI 识别操作已移除" tabindex="0">
|
|
181
180
|
<WarningFilled />
|
|
182
181
|
</el-icon>
|
|
183
182
|
</el-tooltip>
|
|
@@ -197,12 +197,6 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
197
197
|
<LoopSettings v-if="step.type === 'loop'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
198
198
|
<BreakLoopSettings v-if="step.type === 'breakLoop'" :step="step" :steps="steps" :disabled="disabled" @update="patchStep" />
|
|
199
199
|
<TextClickSettings v-if="step.type === 'textClick'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
200
|
-
<el-form-item v-if="step.type === 'aiRecognition'" label="识别内容">
|
|
201
|
-
<el-input
|
|
202
|
-
:model-value="step.value || ''" type="textarea" :rows="3" maxlength="4000"
|
|
203
|
-
:disabled="disabled" @update:model-value="patchStep({ value: String($event) })"
|
|
204
|
-
/>
|
|
205
|
-
</el-form-item>
|
|
206
200
|
<el-form-item
|
|
207
201
|
v-if="step.type === 'input' || step.type === 'inputIfExists' || step.type === 'assertText'"
|
|
208
202
|
label="文本内容"
|
|
@@ -18,7 +18,7 @@ const slots = computed(() => props.config.mode === 'state'
|
|
|
18
18
|
? [{ key: 'template' as const, label: '选中模板' }, { key: 'negativeTemplate' as const, label: '未选中模板' }]
|
|
19
19
|
: props.config.mode === 'template' ? [{ key: 'template' as const, label: '模板' }] : []);
|
|
20
20
|
const numericFields = computed(() => [
|
|
21
|
-
...(['template', 'state'].includes(props.config.mode) ? [{ key: 'threshold' as const, label: '
|
|
21
|
+
...(['template', 'state'].includes(props.config.mode) ? [{ key: 'threshold' as const, label: '匹配严格度 (%)', min: 1, max: 100, step: 1 }] : []),
|
|
22
22
|
...(props.config.mode === 'state' ? [{ key: 'minScoreGap' as const, label: '两种状态最小得分差', min: 0.001, max: 1, step: 0.01 }] : []),
|
|
23
23
|
...(['black', 'color', 'change'].includes(props.config.mode) ? [
|
|
24
24
|
{ key: 'tolerance' as const, label: props.config.mode === 'black' ? '暗色亮度阈值 (0–255)' : 'RGB 通道容差 (0–255)', min: 0, max: 255, step: 1 },
|
|
@@ -110,10 +110,10 @@ async function upload(event: Event) {
|
|
|
110
110
|
<el-alert v-if="regionIssue" :title="regionIssue" type="warning" show-icon :closable="false" class="image-check-region-warning">
|
|
111
111
|
<el-button v-if="expandedRegion" size="small" @click="patch({ region: expandedRegion })">扩大检测区域</el-button>
|
|
112
112
|
</el-alert>
|
|
113
|
-
<el-form-item v-if="config.mode === 'template' || config.mode === 'change'" label="判断条件">
|
|
113
|
+
<el-form-item v-if="config.mode === 'template' || config.mode === 'change'" :label="config.mode === 'template' ? '预期匹配结果' : '判断条件'">
|
|
114
114
|
<el-radio-group :model-value="config.expectation" @update:model-value="patch({ expectation: $event as 'present' | 'absent' })">
|
|
115
|
-
<el-radio-button value="present">{{ config.mode === 'template' ? '
|
|
116
|
-
<el-radio-button value="absent">{{ config.mode === 'template' ? '
|
|
115
|
+
<el-radio-button value="present">{{ config.mode === 'template' ? '匹配到模板' : '画面有变化' }}</el-radio-button>
|
|
116
|
+
<el-radio-button value="absent">{{ config.mode === 'template' ? '未匹配到模板' : '持续无明显变化' }}</el-radio-button>
|
|
117
117
|
</el-radio-group>
|
|
118
118
|
</el-form-item>
|
|
119
119
|
<el-form-item v-if="config.mode === 'color'" label="目标颜色 #RRGGBB">
|
|
@@ -123,7 +123,22 @@ async function upload(event: Event) {
|
|
|
123
123
|
<div class="image-check-grid">
|
|
124
124
|
<template v-for="field in numericFields" :key="field.key">
|
|
125
125
|
<el-form-item :label="field.label">
|
|
126
|
-
<
|
|
126
|
+
<template v-if="field.key === 'threshold'" #label>
|
|
127
|
+
<span class="image-check-threshold-label">{{ field.label }}
|
|
128
|
+
<el-tooltip effect="dark" placement="top" :show-after="200">
|
|
129
|
+
<template #content>
|
|
130
|
+
<div class="image-check-threshold-help">
|
|
131
|
+
当前画面与参考模板的匹配得分达到此值,才算匹配成功,得分不是正确概率。
|
|
132
|
+
数值越高越严格,可能漏掉有细微变化的目标;越低越宽松,可能误认相似图案。
|
|
133
|
+
例如设为 98%,需要匹配得分达到 98%。设为 100% 时,极细微的像素差异也会判为不匹配。图片状态判断还会结合两种模板的最小得分差,得分太接近时无法判定。
|
|
134
|
+
</div>
|
|
135
|
+
</template>
|
|
136
|
+
<el-button class="image-check-threshold-button" :icon="QuestionFilled" text aria-label="匹配严格度说明" @click.prevent />
|
|
137
|
+
</el-tooltip>
|
|
138
|
+
</span>
|
|
139
|
+
</template>
|
|
140
|
+
<el-input-number :model-value="field.key === 'threshold' ? Number((config.threshold * 100).toFixed(8)) : config[field.key]" :min="field.min" :max="field.max" :step="field.step" controls-position="right" @update:model-value="$event !== undefined && patch({ [field.key]: field.key === 'threshold' ? $event / 100 : $event })" />
|
|
141
|
+
<el-alert v-if="field.key === 'threshold' && config.threshold === 1" title="100% 要求完全匹配,极细微的像素差异也会判为不匹配。" type="warning" :closable="false" show-icon class="image-check-strict-warning" />
|
|
127
142
|
</el-form-item>
|
|
128
143
|
<BranchTimeoutSettings v-if="field.key === 'durationMs'" style="grid-column: 1 / -1" :step="{ id: '', label: '', type: 'imageCheck', timeoutBranch }" :disabled="busy" @update="emit('timeoutBranch', $event)" />
|
|
129
144
|
</template>
|
|
@@ -141,9 +156,17 @@ async function upload(event: Event) {
|
|
|
141
156
|
.image-check-dialog .el-input-number { width: 100%; }
|
|
142
157
|
.image-check-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 12px; }
|
|
143
158
|
.image-check-help { margin-left: 8px; color: #606266; }
|
|
159
|
+
.image-check-grid .el-form-item__label { display: flex; align-items: center; min-height: 20px; }
|
|
160
|
+
.image-check-threshold-label { display: inline-flex; align-items: center; gap: 4px; }
|
|
161
|
+
.image-check-dialog .el-button.image-check-threshold-button { flex: 0 0 20px; width: 20px; height: 20px; min-height: 20px; padding: 0; font-size: 14px; }
|
|
162
|
+
.image-check-threshold-help { max-width: min(300px, calc(100vw - 40px)); font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; }
|
|
163
|
+
.image-check-strict-warning { margin-top: 6px; }
|
|
144
164
|
.image-check-template { display: grid; gap: 6px; margin-bottom: 16px; min-width: 0; }
|
|
145
165
|
.image-check-size { color: #606266; font-size: 12px; }
|
|
146
166
|
.image-check-region-warning { margin-bottom: 16px; }
|
|
147
167
|
.image-check-template .el-image, .image-check-empty { height: 96px; width: 100%; background: #eff5f3; border: 1px solid #d5dfdb; border-radius: 4px; }
|
|
148
168
|
.image-check-empty { display: grid; place-items: center; color: #606266; }
|
|
169
|
+
@media (max-width: 480px) {
|
|
170
|
+
.image-check-grid { grid-template-columns: minmax(0, 1fr); }
|
|
171
|
+
}
|
|
149
172
|
</style>
|
|
@@ -119,7 +119,6 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
|
|
|
119
119
|
actions: [
|
|
120
120
|
{ type: 'delay', label: '添加延时' },
|
|
121
121
|
{ type: 'popupCondition', label: '判断存在' },
|
|
122
|
-
{ type: 'aiRecognition', label: 'AI 识别' },
|
|
123
122
|
{ type: 'imageCheck', label: '图像判断' },
|
|
124
123
|
{ type: 'tapIfExists', label: '存在则点击' },
|
|
125
124
|
{ type: 'inputIfExists', label: '存在则输入' },
|
|
@@ -89,7 +89,6 @@ const insertActionGroups: FlowActionGroup[] = [
|
|
|
89
89
|
actions: [
|
|
90
90
|
{ type: 'delay', label: '添加延时' },
|
|
91
91
|
{ type: 'popupCondition', label: '判断存在' },
|
|
92
|
-
{ type: 'aiRecognition', label: 'AI 识别' },
|
|
93
92
|
{ type: 'imageCheck', label: '图像判断' },
|
|
94
93
|
{ type: 'tapIfExists', label: '存在则点击' },
|
|
95
94
|
{ type: 'inputIfExists', label: '存在则输入' },
|
|
@@ -460,8 +460,8 @@ export function buildFlowGraph(
|
|
|
460
460
|
&& item.step.type !== 'clearAppData'
|
|
461
461
|
&& item.step.visualChange?.role !== 'end',
|
|
462
462
|
canEditInput: ['input', 'inputIfExists', 'imageCheck', 'runScript'].includes(item.step.type),
|
|
463
|
-
canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData'
|
|
464
|
-
missingAiModel: item.step.type === 'aiRecognition'
|
|
463
|
+
canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData',
|
|
464
|
+
missingAiModel: item.step.type === 'aiRecognition',
|
|
465
465
|
},
|
|
466
466
|
});
|
|
467
467
|
};
|
|
@@ -47,7 +47,7 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
|
|
|
47
47
|
checkboxState: 'Checkbox 状态',
|
|
48
48
|
checkedState: '判断勾选',
|
|
49
49
|
radioButtonState: 'RadioButton 状态',
|
|
50
|
-
aiRecognition: 'AI
|
|
50
|
+
aiRecognition: 'AI 识别(已移除)',
|
|
51
51
|
imageCheck: '图像判断',
|
|
52
52
|
textClick: '文字点击',
|
|
53
53
|
assertText: '断言文本',
|
|
@@ -81,7 +81,7 @@ export function flowStepMeta(step: AppiumRecordedStep) {
|
|
|
81
81
|
if (step.type === 'loop') return `最多 ${step.loop?.maxIterations ?? '?'} 次 · ${step.loop?.exitWhen === 'exists' ? '元素出现时退出' : step.loop?.exitWhen === 'notExists' ? '元素消失时退出' : '固定次数'}${step.loop?.exitWhen !== 'never' ? ` ${step.selector?.value || ''}` : ''}`;
|
|
82
82
|
if (step.type === 'breakLoop') return step.breakLoopTargetId ? '退出指定循环,继续循环结束后的流程' : '退出当前循环,继续循环结束后的流程';
|
|
83
83
|
if (step.type === 'log') return `${step.logPrefix ?? DEFAULT_LOG_PREFIX}:${step.value || ''}`;
|
|
84
|
-
if (step.type === 'aiRecognition') return
|
|
84
|
+
if (step.type === 'aiRecognition') return '此操作已移除,请替换为图像判断或原生组件判断';
|
|
85
85
|
if (step.type === 'longPress') {
|
|
86
86
|
const target = longPressMode(step) === 'element'
|
|
87
87
|
? `元素 ${step.selector?.strategy || ''} ${step.selector?.value || ''}`
|
|
@@ -21,6 +21,7 @@ export type ImageCheckConfig = {
|
|
|
21
21
|
consecutive: number;
|
|
22
22
|
};
|
|
23
23
|
export type ImageCheckResult = {
|
|
24
|
+
templateMatch?: { matched: boolean; expected: 'present' | 'absent'; score: number; threshold: number };
|
|
24
25
|
timedOut?: boolean;
|
|
25
26
|
result: boolean | null;
|
|
26
27
|
mode: ImageCheckConfig['mode'];
|
|
@@ -115,5 +116,6 @@ export function validateImageCheck(config: ImageCheckConfig | undefined) {
|
|
|
115
116
|
|
|
116
117
|
export function imageCheckSummary(config?: ImageCheckConfig) {
|
|
117
118
|
if (!config) return '未配置图像判断';
|
|
118
|
-
|
|
119
|
+
const expectation = config.mode === 'template' ? ` · ${config.expectation === 'present' ? '匹配到模板' : '未匹配到模板'}` : '';
|
|
120
|
+
return `${IMAGE_CHECK_MODES[config.mode]}${expectation} · ${config.target === 'element' ? '组件区域' : '框选区域'} · ${config.durationMs}ms`;
|
|
119
121
|
}
|