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.
Files changed (28) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +11 -0
  3. package/package.json +12 -7
  4. package/server/appium-recorder/appium-runner.ts +61 -16
  5. package/server/appium-recorder/image-check.ts +10 -0
  6. package/server/appium-recorder/replay-mp4.ts +72 -0
  7. package/server/appium-recorder/replay-video-segments.ts +77 -0
  8. package/server/appium-recorder/replay-video.ts +107 -0
  9. package/server/appium-recorder/report.ts +98 -6
  10. package/server/appium-recorder/routes.ts +16 -17
  11. package/server/appium-recorder/run-history.ts +9 -1
  12. package/server/appium-recorder/scrcpy-recording-source.ts +80 -0
  13. package/server/appium-recorder/video-response.ts +36 -0
  14. package/src/appium-recorder/AppiumPage.vue +24 -41
  15. package/src/appium-recorder/RunHistoryDialog.vue +13 -3
  16. package/src/appium-recorder/action-descriptions.ts +1 -1
  17. package/src/appium-recorder/api.ts +3 -10
  18. package/src/appium-recorder/components/FlowNodeCard.vue +2 -3
  19. package/src/appium-recorder/components/FlowStepEditor.vue +0 -6
  20. package/src/appium-recorder/components/ImageCheckDialog.vue +28 -5
  21. package/src/appium-recorder/components/NestedConditionBranches.vue +0 -1
  22. package/src/appium-recorder/components/RecordedSteps.vue +0 -1
  23. package/src/appium-recorder/flow-graph.ts +2 -2
  24. package/src/appium-recorder/flow-labels.ts +2 -2
  25. package/src/appium-recorder/image-check.ts +3 -1
  26. package/src/appium-recorder/run-history.ts +3 -0
  27. package/src/components/config/AppiumConfigPanel.vue +0 -35
  28. 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 entries = [['结果', check.timedOut ? '观察超时' : check.result === null ? '无法判定' : String(check.result)], ['配置', check.settings],
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
- const parsed = await readBody<{ deviceId?: string; prompt?: unknown; timeoutMs?: number }>(req);
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
- const input = await ElMessageBox.prompt('识别内容', '添加 AI 识别', {
1395
- inputType: 'textarea',
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
- type="primary"
2172
- :icon="VideoPlay"
2173
- :loading="replaying"
2174
- :disabled="!selectedScript"
2175
- @click="replayScript"
2176
- >
2177
- 回放
2178
- </el-button>
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} 条历史及截图、日志快照?原始报告文件不受影响。`, '删除历史结果', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }); }
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: '用配置的模型判断当前画面,返回 true/false。适合语义、提示含义和复杂画面判断;可直接读取的组件属性优先使用原生判断。需要配置 AI 节点模型。',
29
+ aiRecognition: '此操作已移除,请使用图像判断或原生组件判断。',
30
30
  textClick: '在组件树中按指定文字精确或模糊匹配并点击,进入匹配到文字或未匹配到文字分支。不识别图片或自绘画面中的文字。',
31
31
  tapIfExists: '找到目标组件时点击,未找到则跳过并继续后续流程。',
32
32
  inputIfExists: '找到目标输入组件时写入文字,未找到则跳过。支持 {{变量名}} 引用。',