android-midscene-automation 0.1.38 → 0.1.40
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 +12 -7
- package/server/appium-recorder/appium-runner.ts +61 -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-segments.ts +77 -0
- package/server/appium-recorder/replay-video.ts +107 -0
- package/server/appium-recorder/report.ts +98 -6
- package/server/appium-recorder/routes.ts +16 -17
- package/server/appium-recorder/run-history.ts +9 -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 +13 -3
- 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 +3 -0
- package/src/components/config/AppiumConfigPanel.vue +0 -35
- package/src/appium-recorder/components/AiRecognitionTestDialog.vue +0 -71
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import type { ReplayVideo } from './replay-video';
|
|
3
4
|
import { PNG } from 'pngjs';
|
|
4
5
|
import { appDataPath } from '../paths';
|
|
5
6
|
import { loadConfig } from '../config';
|
|
@@ -240,6 +241,7 @@ function stepSection(
|
|
|
240
241
|
}
|
|
241
242
|
|
|
242
243
|
function createReplayHtml(input: {
|
|
244
|
+
video?: { url: string; startedAt: string; segments?: Array<{ url: string; startedAt: string; boundaryAt?: string; scriptName?: string }> };
|
|
243
245
|
script: AppiumRecordedScriptRecord;
|
|
244
246
|
deviceId: string;
|
|
245
247
|
resultText: string;
|
|
@@ -253,6 +255,7 @@ function createReplayHtml(input: {
|
|
|
253
255
|
}) {
|
|
254
256
|
const imageCache = new Map<string, string>();
|
|
255
257
|
const payload = jsonForHtml({
|
|
258
|
+
video: input.video,
|
|
256
259
|
startedAt: input.startedAt.toISOString(),
|
|
257
260
|
durationMs: input.durationMs,
|
|
258
261
|
frames: input.frames.map((frame) => ({
|
|
@@ -325,9 +328,11 @@ function createReplayHtml(input: {
|
|
|
325
328
|
.viewer { display: grid; grid-template-rows: minmax(0, 1fr) auto auto; min-width: 0; min-height: 0; padding: 16px 20px 12px; background: #f4f6f8; }
|
|
326
329
|
.screen { display: flex; align-items: center; justify-content: center; min-height: 0; overflow: hidden; border-radius: 6px; background: #101827; }
|
|
327
330
|
.screen img { display: block; width: 100%; height: 100%; object-fit: contain; transition: opacity .12s ease; }
|
|
331
|
+
[hidden] { display: none !important; }
|
|
328
332
|
.empty { color: #a8abb2; }
|
|
329
333
|
.caption { min-height: 40px; padding: 10px 4px 0; overflow: hidden; color: #4b515a; font-size: 13px; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
|
|
330
334
|
.controls { display: grid; grid-template-columns: auto auto auto minmax(120px, 1fr) auto; align-items: center; gap: 8px; min-height: 52px; }
|
|
335
|
+
.controls:has(#toggle-recording:not([hidden])) { grid-template-columns: auto auto auto auto minmax(80px, 1fr) auto; }
|
|
331
336
|
button { min-height: 34px; padding: 0 14px; border: 1px solid #cfd5dd; border-radius: 4px; color: #30343b; background: #fff; cursor: pointer; }
|
|
332
337
|
button:hover:not(:disabled) { border-color: #409eff; color: #1677ff; }
|
|
333
338
|
button:disabled { cursor: not-allowed; opacity: .45; }
|
|
@@ -364,6 +369,9 @@ function createReplayHtml(input: {
|
|
|
364
369
|
.thumbnail { height: 78px; }
|
|
365
370
|
header { align-items: flex-start; flex-direction: column; }
|
|
366
371
|
.meta { justify-content: flex-start; }
|
|
372
|
+
.controls, .controls:has(#toggle-recording:not([hidden])) { display: flex; flex-wrap: wrap; }
|
|
373
|
+
.controls button { max-width: 100%; }
|
|
374
|
+
#seek { flex: 1 1 120px; width: auto; }
|
|
367
375
|
}
|
|
368
376
|
</style>
|
|
369
377
|
</head>
|
|
@@ -406,9 +414,11 @@ function createReplayHtml(input: {
|
|
|
406
414
|
</div>
|
|
407
415
|
</div>
|
|
408
416
|
<section class="viewer">
|
|
409
|
-
<div class="screen"><img id="screen" alt="设备截图" hidden /><span id="empty" class="empty">本次回放没有可用截图</span></div>
|
|
417
|
+
<div class="screen"><video id="recording" controls preload="metadata" playsinline hidden style="width:100%;height:100%;object-fit:contain"></video><img id="screen" alt="设备截图" hidden /><span id="empty" class="empty">本次回放没有可用截图</span></div>
|
|
410
418
|
<div id="caption" class="caption">暂无截图</div>
|
|
411
419
|
<div class="controls">
|
|
420
|
+
<button id="toggle-recording" hidden>查看节点截图</button>
|
|
421
|
+
<select id="video-segment" aria-label="视频片段" hidden style="max-width:100%;min-width:0"></select>
|
|
412
422
|
<button id="previous" type="button" title="上一帧">上一帧</button>
|
|
413
423
|
<button id="play" type="button">播放</button>
|
|
414
424
|
<button id="next" type="button" title="下一帧">下一帧</button>
|
|
@@ -430,6 +440,21 @@ function createReplayHtml(input: {
|
|
|
430
440
|
const data = JSON.parse(document.querySelector('#replay-data').textContent || '{}');
|
|
431
441
|
const visualChecks = Array.isArray(data.visualChecks) ? data.visualChecks : [];
|
|
432
442
|
const reportStartedAt = Date.parse(data.startedAt) || 0;
|
|
443
|
+
const recording = document.querySelector('#recording');
|
|
444
|
+
const toggleRecording = document.querySelector('#toggle-recording');
|
|
445
|
+
const videos = data.video ? (data.video.segments || [data.video]) : [];
|
|
446
|
+
const segmentSelect = document.querySelector('#video-segment');
|
|
447
|
+
let activeVideo = 0;
|
|
448
|
+
let videoOffset = Math.max(0, (Date.parse(videos[0]?.startedAt) || reportStartedAt) - reportStartedAt);
|
|
449
|
+
videos.forEach((video, index) => {
|
|
450
|
+
const option = document.createElement('option');
|
|
451
|
+
option.value = String(index); option.textContent = (index + 1) + '. ' + (video.scriptName || data.scriptName || '回放视频');
|
|
452
|
+
segmentSelect.appendChild(option);
|
|
453
|
+
});
|
|
454
|
+
segmentSelect.hidden = videos.length < 2;
|
|
455
|
+
let videoMode = Boolean(data.video);
|
|
456
|
+
let pendingVideoTime = 0;
|
|
457
|
+
if (data.video) { recording.src = data.video.url; toggleRecording.hidden = false; }
|
|
433
458
|
const frames = (Array.isArray(data.frames) ? data.frames : []).map((frame) => ({
|
|
434
459
|
...frame,
|
|
435
460
|
offsetMs: Math.max(0, (Date.parse(frame.capturedAt) || reportStartedAt) - reportStartedAt),
|
|
@@ -487,8 +512,9 @@ function createReplayHtml(input: {
|
|
|
487
512
|
const frameChanged = nextFrame !== currentFrame;
|
|
488
513
|
currentFrame = nextFrame;
|
|
489
514
|
const frame = frames[currentFrame];
|
|
490
|
-
elements.empty.hidden = Boolean(frame);
|
|
491
|
-
elements.screen.hidden = !frame;
|
|
515
|
+
elements.empty.hidden = videoMode || Boolean(frame?.imageUrl);
|
|
516
|
+
elements.screen.hidden = videoMode || !frame?.imageUrl;
|
|
517
|
+
recording.hidden = !videoMode;
|
|
492
518
|
if (frame) {
|
|
493
519
|
if (frameChanged || elements.screen.src !== frame.imageUrl) elements.screen.src = frame.imageUrl;
|
|
494
520
|
elements.title.textContent = frame.nodeLabel || '未命名节点';
|
|
@@ -507,7 +533,7 @@ function createReplayHtml(input: {
|
|
|
507
533
|
}
|
|
508
534
|
elements.previous.disabled = !frames.length || currentFrame <= 0;
|
|
509
535
|
elements.next.disabled = !frames.length || currentFrame >= frames.length - 1;
|
|
510
|
-
elements.play.disabled = frames.length < 2;
|
|
536
|
+
elements.play.disabled = !videoMode && frames.length < 2;
|
|
511
537
|
elements.seek.value = String(Math.round(currentMs / durationMs * 1000));
|
|
512
538
|
elements.clock.textContent = formatTime(currentMs) + ' / ' + formatTime(durationMs);
|
|
513
539
|
elements.playhead.style.left = (currentMs / durationMs * 100) + '%';
|
|
@@ -516,14 +542,61 @@ function createReplayHtml(input: {
|
|
|
516
542
|
if (scrollStep) document.querySelector('.frame-item.active')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
517
543
|
};
|
|
518
544
|
const pause = () => {
|
|
545
|
+
recording.pause();
|
|
519
546
|
cancelAnimationFrame(animationFrame);
|
|
520
547
|
animationFrame = 0;
|
|
521
548
|
elements.play.textContent = '播放';
|
|
522
549
|
};
|
|
523
550
|
const seekTo = (milliseconds, scrollStep = true) => {
|
|
524
551
|
currentMs = Math.max(0, Math.min(durationMs, milliseconds));
|
|
552
|
+
let index = 0;
|
|
553
|
+
for (let i = 1; i < videos.length; i++) {
|
|
554
|
+
if (Date.parse(videos[i].boundaryAt || videos[i].startedAt) - reportStartedAt <= currentMs) index = i;
|
|
555
|
+
}
|
|
556
|
+
if (index !== activeVideo) {
|
|
557
|
+
activeVideo = index; segmentSelect.value = String(index);
|
|
558
|
+
videoOffset = Math.max(0, Date.parse(videos[index].startedAt) - reportStartedAt);
|
|
559
|
+
recording.src = videos[index].url;
|
|
560
|
+
}
|
|
561
|
+
pendingVideoTime = Math.max(0, (currentMs - videoOffset) / 1000);
|
|
562
|
+
if (data.video && recording.readyState >= 1) recording.currentTime = Math.min(pendingVideoTime, recording.duration || pendingVideoTime);
|
|
525
563
|
render(scrollStep);
|
|
526
564
|
};
|
|
565
|
+
recording.addEventListener('loadedmetadata', () => { recording.currentTime = Math.min(pendingVideoTime, recording.duration); });
|
|
566
|
+
segmentSelect.addEventListener('change', () => {
|
|
567
|
+
pause();
|
|
568
|
+
const video = videos[Number(segmentSelect.value)];
|
|
569
|
+
seekTo(Date.parse(video.boundaryAt || video.startedAt) - reportStartedAt);
|
|
570
|
+
});
|
|
571
|
+
recording.addEventListener('ended', () => {
|
|
572
|
+
if (videoMode && activeVideo + 1 < videos.length) {
|
|
573
|
+
const next = videos[activeVideo + 1];
|
|
574
|
+
seekTo(Date.parse(next.boundaryAt || next.startedAt) - reportStartedAt);
|
|
575
|
+
recording.play().catch(() => undefined);
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
recording.addEventListener('timeupdate', () => {
|
|
579
|
+
if (!videoMode || recording.paused) return;
|
|
580
|
+
currentMs = recording.currentTime * 1000 + videoOffset;
|
|
581
|
+
render();
|
|
582
|
+
});
|
|
583
|
+
recording.addEventListener('seeked', () => {
|
|
584
|
+
if (!videoMode) return;
|
|
585
|
+
currentMs = recording.currentTime * 1000 + videoOffset;
|
|
586
|
+
render();
|
|
587
|
+
});
|
|
588
|
+
recording.addEventListener('play', () => { elements.play.textContent = '暂停'; });
|
|
589
|
+
recording.addEventListener('pause', () => { elements.play.textContent = '播放'; });
|
|
590
|
+
recording.addEventListener('error', () => {
|
|
591
|
+
pause(); videoMode = false; render();
|
|
592
|
+
toggleRecording.textContent = '视频不可用,请将 MP4 与报告放在一起';
|
|
593
|
+
toggleRecording.disabled = true;
|
|
594
|
+
});
|
|
595
|
+
toggleRecording.addEventListener('click', () => {
|
|
596
|
+
pause(); videoMode = !videoMode;
|
|
597
|
+
toggleRecording.textContent = videoMode ? '查看节点截图' : '查看回放视频';
|
|
598
|
+
seekTo(currentMs, false);
|
|
599
|
+
});
|
|
527
600
|
const tickPlayback = (now) => {
|
|
528
601
|
currentMs = Math.min(durationMs, now - playbackStartedAt);
|
|
529
602
|
render();
|
|
@@ -605,7 +678,12 @@ function createReplayHtml(input: {
|
|
|
605
678
|
article.append(title);
|
|
606
679
|
const detail = document.createElement('dl');
|
|
607
680
|
const region = check.region || {};
|
|
608
|
-
const
|
|
681
|
+
const match = check.templateMatch;
|
|
682
|
+
const entries = [
|
|
683
|
+
...(match ? [['实际匹配结果', match.matched ? '匹配到模板' : '未匹配到模板'],
|
|
684
|
+
['预期匹配结果', match.expected === 'present' ? '匹配到模板' : '未匹配到模板'],
|
|
685
|
+
['匹配得分 / 严格度', (match.score * 100).toFixed(4) + '% / ' + (match.threshold * 100).toFixed(4) + '%']] : []),
|
|
686
|
+
[match ? '条件是否成立' : '结果', check.timedOut ? '观察超时(按节点超时配置处理)' : check.result === null ? '无法判定' : match ? (check.result ? '成立 → true 分支' : '不成立 → false 分支') : String(check.result)], ['配置', check.settings],
|
|
609
687
|
['区域', [region.x, region.y, region.width, region.height].join(', ')],
|
|
610
688
|
['采样', check.sampleCount + ' 帧'], ['耗时', check.durationMs + 'ms'], ['说明', check.message], ...Object.entries(check.metrics)];
|
|
611
689
|
entries.forEach(([key, value]) => {
|
|
@@ -685,6 +763,11 @@ function createReplayHtml(input: {
|
|
|
685
763
|
elements.previous.addEventListener('click', () => { pause(); seekTo(frames[Math.max(0, currentFrame - 1)]?.offsetMs || 0); });
|
|
686
764
|
elements.next.addEventListener('click', () => { pause(); seekTo(frames[Math.min(frames.length - 1, currentFrame + 1)]?.offsetMs || durationMs); });
|
|
687
765
|
elements.play.addEventListener('click', () => {
|
|
766
|
+
if (videoMode) {
|
|
767
|
+
if (!recording.paused) return pause();
|
|
768
|
+
recording.play().catch(() => { elements.caption.textContent = '视频无法播放,请检查 MP4 文件是否存在或使用其他浏览器'; });
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
688
771
|
if (animationFrame) return pause();
|
|
689
772
|
if (currentMs >= durationMs) currentMs = 0;
|
|
690
773
|
playbackStartedAt = performance.now() - currentMs;
|
|
@@ -719,6 +802,7 @@ function createReplayHtml(input: {
|
|
|
719
802
|
}
|
|
720
803
|
|
|
721
804
|
export async function createAppiumReplayReport(input: {
|
|
805
|
+
video?: ReplayVideo;
|
|
722
806
|
script: AppiumRecordedScriptRecord;
|
|
723
807
|
deviceId: string;
|
|
724
808
|
success: boolean;
|
|
@@ -753,6 +837,14 @@ export async function createAppiumReplayReport(input: {
|
|
|
753
837
|
const visualCheckByStepId = new Map(visualChecks.map((check) => [check.nodeId, check]));
|
|
754
838
|
const visualFailureCount = visualChecks.filter((check) => check.status === 'failed').length;
|
|
755
839
|
const html = createReplayHtml({
|
|
840
|
+
video: input.video ? {
|
|
841
|
+
startedAt: input.video.startedAt,
|
|
842
|
+
url: relative(outputDir, input.video.filePath).split(/[/\\\\]/).map(encodeURIComponent).join('/'),
|
|
843
|
+
segments: (input.video.segments || [input.video]).map(segment => ({
|
|
844
|
+
startedAt: segment.startedAt, boundaryAt: segment.boundaryAt, scriptName: segment.scriptName,
|
|
845
|
+
url: relative(outputDir, segment.filePath).split(/[/\\\\]/).map(encodeURIComponent).join('/'),
|
|
846
|
+
})),
|
|
847
|
+
} : undefined,
|
|
756
848
|
script: input.script,
|
|
757
849
|
deviceId: input.deviceId,
|
|
758
850
|
resultText,
|
|
@@ -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,17 @@ 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
|
+
const index = Number(new URL(req.url || '', 'http://localhost').searchParams.get('segment') || 0);
|
|
341
|
+
const segment = Number.isSafeInteger(index) && index >= 0 ? (run.video.segments || [run.video])[index] : undefined;
|
|
342
|
+
if (!segment) { sendJson(res, { error: '视频片段不存在' }, 404); return true; }
|
|
343
|
+
await sendReplayVideo(req, res, segment.filePath);
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
348
346
|
const historyMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/history(?:\/([^/]+))?$/);
|
|
349
347
|
if (historyMatch) {
|
|
350
348
|
const scriptId = decodeURIComponent(historyMatch[1]);
|
|
@@ -362,12 +360,13 @@ export async function handleAppiumRecorderRequest(
|
|
|
362
360
|
}
|
|
363
361
|
const replayMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/replay$/);
|
|
364
362
|
if (replayMatch && req.method === 'POST') {
|
|
365
|
-
const parsed = await readBody<{ deviceId?: string; parameters?: TestVariable[] }>(req);
|
|
363
|
+
const parsed = await readBody<{ deviceId?: string; parameters?: TestVariable[]; recordVideo?: boolean }>(req);
|
|
366
364
|
const script = getAppiumRecordedScript(decodeURIComponent(replayMatch[1]));
|
|
367
365
|
if (!script) throw new Error('Appium 录制脚本不存在');
|
|
368
366
|
const deviceId = parsed.deviceId || selectedDeviceId;
|
|
369
367
|
assertDeviceAllowed(deviceId);
|
|
370
368
|
const streamOutput = req.headers.accept?.includes('application/x-ndjson') === true;
|
|
369
|
+
if (parsed.recordVideo === true && isRemoteDeviceId(deviceId)) throw new Error('远程设备暂不支持回放录屏,请在连接设备的主机本地运行');
|
|
371
370
|
if (streamOutput) {
|
|
372
371
|
res.statusCode = 200;
|
|
373
372
|
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
@@ -403,7 +402,7 @@ export async function handleAppiumRecorderRequest(
|
|
|
403
402
|
deviceId,
|
|
404
403
|
streamOutput ? (line) => sendStreamEvent(res, { type: 'log', line }) : undefined,
|
|
405
404
|
replayAbortController.signal,
|
|
406
|
-
{ parameters: parsed.parameters, globalVariables: getPresetVariables() },
|
|
405
|
+
{ parameters: parsed.parameters, globalVariables: getPresetVariables(), recordVideo: parsed.recordVideo === true },
|
|
407
406
|
);
|
|
408
407
|
try { saveRunHistory(result.history); }
|
|
409
408
|
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,12 @@ 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
|
+
for (const segment of video?.segments || (video ? [video] : [])) {
|
|
37
|
+
if (basename(segment.filePath) === segment.fileName && /^replay-[\d]+-[\da-f-]+\.mp4$/.test(segment.fileName)) {
|
|
38
|
+
rmSync(segment.filePath, { force: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
33
41
|
runSql(`PRAGMA secure_delete=ON; DELETE FROM appium_run_history WHERE script_id=${sqlString(scriptId)} AND id=${sqlString(id)};`);
|
|
34
42
|
}
|
|
@@ -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
|
-
import { computed, onMounted, ref } from 'vue';
|
|
3
|
-
import { Delete, Refresh } from '@element-plus/icons-vue';
|
|
2
|
+
import { computed, onMounted, ref, watch } from '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,9 @@ const device = ref('');
|
|
|
16
16
|
const error = ref('');
|
|
17
17
|
const nodeKey = ref('');
|
|
18
18
|
const logQuery = ref('');
|
|
19
|
+
const videoRun = ref<RunSummary>();
|
|
20
|
+
const videoSegment = ref(0);
|
|
21
|
+
watch(videoRun, () => { videoSegment.value = 0; }, { flush: 'sync' });
|
|
19
22
|
const base = `/api/appium-recorder/scripts/${encodeURIComponent(props.scriptId)}/history`;
|
|
20
23
|
const labels = { passed: '通过', failed: '失败', stopped: '已终止' };
|
|
21
24
|
const versions = computed(() => [...new Set(runs.value.map(run => run.appVersion || '未知'))]);
|
|
@@ -49,7 +52,7 @@ async function compare() {
|
|
|
49
52
|
finally { comparing.value = false; }
|
|
50
53
|
}
|
|
51
54
|
async function remove() {
|
|
52
|
-
try { await ElMessageBox.confirm(`删除选中的 ${selected.value.length}
|
|
55
|
+
try { await ElMessageBox.confirm(`删除选中的 ${selected.value.length} 条历史及截图、日志快照和回放视频?原始报告将无法再播放已删除的视频。`, '删除历史结果', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }); }
|
|
53
56
|
catch { return; }
|
|
54
57
|
loading.value = true;
|
|
55
58
|
try {
|
|
@@ -98,6 +101,7 @@ onMounted(load);
|
|
|
98
101
|
<el-table-column prop="deviceId" label="设备" min-width="140" />
|
|
99
102
|
<el-table-column label="结果" width="85"><template #default="{ row }">{{ labels[row.status as keyof typeof labels] }}</template></el-table-column>
|
|
100
103
|
<el-table-column label="耗时" width="100"><template #default="{ row }">{{ seconds(row.durationMs) }}</template></el-table-column>
|
|
104
|
+
<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
105
|
</el-table>
|
|
102
106
|
<h3>失败节点分布</h3>
|
|
103
107
|
<el-table :data="stats.failures" max-height="220" empty-text="暂无节点失败记录">
|
|
@@ -129,6 +133,12 @@ onMounted(load);
|
|
|
129
133
|
</div>
|
|
130
134
|
</section>
|
|
131
135
|
</div>
|
|
136
|
+
<el-dialog :model-value="Boolean(videoRun)" title="回放视频" width="min(800px, 90vw)" align-center append-to-body destroy-on-close @close="videoRun = undefined">
|
|
137
|
+
<el-select v-if="videoRun?.video?.segments?.length" v-model="videoSegment" aria-label="视频片段" style="width:100%;margin-bottom:12px">
|
|
138
|
+
<el-option v-for="(segment, index) in videoRun.video.segments" :key="segment.fileName" :label="`${index + 1}. ${segment.scriptName || videoRun.scriptName}`" :value="index" />
|
|
139
|
+
</el-select>
|
|
140
|
+
<video v-if="videoRun" :src="`${base}/${encodeURIComponent(videoRun.id)}/video?segment=${videoSegment}`" controls preload="metadata" style="width:100%;max-height:70vh" />
|
|
141
|
+
</el-dialog>
|
|
132
142
|
</el-dialog>
|
|
133
143
|
</template>
|
|
134
144
|
|
|
@@ -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: '找到目标输入组件时写入文字,未找到则跳过。支持 {{变量名}} 引用。',
|