android-midscene-automation 0.1.37 → 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.
Files changed (33) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +13 -0
  3. package/package.json +12 -7
  4. package/server/appium-recorder/ai-recognition.ts +4 -2
  5. package/server/appium-recorder/appium-runner.ts +90 -37
  6. package/server/appium-recorder/condition-timeout.ts +4 -0
  7. package/server/appium-recorder/image-check.ts +16 -2
  8. package/server/appium-recorder/replay-mp4.ts +72 -0
  9. package/server/appium-recorder/replay-video.ts +99 -0
  10. package/server/appium-recorder/report.ts +60 -6
  11. package/server/appium-recorder/repository.ts +1 -0
  12. package/server/appium-recorder/routes.ts +13 -17
  13. package/server/appium-recorder/run-history.ts +7 -1
  14. package/server/appium-recorder/scrcpy-recording-source.ts +80 -0
  15. package/server/appium-recorder/video-response.ts +36 -0
  16. package/src/appium-recorder/AppiumPage.vue +42 -98
  17. package/src/appium-recorder/RunHistoryDialog.vue +7 -2
  18. package/src/appium-recorder/action-descriptions.ts +1 -1
  19. package/src/appium-recorder/api.ts +3 -10
  20. package/src/appium-recorder/components/BranchTimeoutSettings.vue +23 -0
  21. package/src/appium-recorder/components/FlowNodeCard.vue +2 -3
  22. package/src/appium-recorder/components/FlowStepEditor.vue +6 -21
  23. package/src/appium-recorder/components/ImageCheckDialog.vue +38 -9
  24. package/src/appium-recorder/components/NestedConditionBranches.vue +0 -1
  25. package/src/appium-recorder/components/RecordedSteps.vue +0 -1
  26. package/src/appium-recorder/flow-graph.ts +2 -2
  27. package/src/appium-recorder/flow-labels.ts +2 -2
  28. package/src/appium-recorder/image-check.ts +6 -2
  29. package/src/appium-recorder/node-timeout.ts +1 -0
  30. package/src/appium-recorder/run-history.ts +1 -0
  31. package/src/appium-recorder/types.ts +1 -0
  32. package/src/components/config/AppiumConfigPanel.vue +0 -35
  33. package/src/appium-recorder/components/AiRecognitionTestDialog.vue +0 -71
@@ -0,0 +1,99 @@
1
+ import { mkdir, open, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { loadConfig } from '../config';
5
+ import { appDataPath } from '../paths';
6
+ import { openRecordingSource, waitForVideoTask } from './scrcpy-recording-source';
7
+ import { ReplayMp4Writer } from './replay-mp4';
8
+
9
+ export type ReplayVideo = { filePath: string; fileName: string; startedAt: string; warning?: string };
10
+
11
+ // 检查 MP4 的索引和媒体块,不将大视频整体读入内存。
12
+ export async function isFinalizedMp4(path: string) {
13
+ const file = await open(path, 'r');
14
+ try {
15
+ const { size } = await file.stat();
16
+ let offset = 0, media = false, index = false;
17
+ const header = Buffer.alloc(16);
18
+ while (offset + 8 <= size) {
19
+ const { bytesRead } = await file.read(header, 0, 16, offset);
20
+ const type = header.toString('ascii', 4, 8);
21
+ let length = header.readUInt32BE(0);
22
+ if (length === 1) {
23
+ if (bytesRead < 16) return false;
24
+ length = Number(header.readBigUInt64BE(8));
25
+ }
26
+ if (!length) length = size - offset;
27
+ if (length < 8 || !Number.isSafeInteger(length) || offset + length > size) return false;
28
+ if (type === 'mdat' && length > 8) media = true;
29
+ if (type === 'moov' && length > 8) index = true;
30
+ offset += length;
31
+ }
32
+ return media && index && offset === size;
33
+ } finally { await file.close(); }
34
+ }
35
+
36
+ export async function startReplayVideo(deviceId: string, signal?: AbortSignal) {
37
+ signal?.throwIfAborted();
38
+ const dir = appDataPath(loadConfig().runtime.reportOutputPath.trim() || 'output');
39
+ await mkdir(dir, { recursive: true });
40
+ const fileName = `replay-${Date.now()}-${randomUUID()}.mp4`;
41
+ const filePath = join(dir, fileName);
42
+ const startupSignal = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(30000)]);
43
+ const source = await openRecordingSource(deviceId, startupSignal);
44
+ return recordVideoStream(source, filePath, fileName, startupSignal);
45
+ }
46
+
47
+ // 独立于连接层,便于用真实 H.264 数据验证封装、断流和终止收尾。
48
+ export async function recordVideoStream(source: Awaited<ReturnType<typeof openRecordingSource>>, filePath: string, fileName: string, startupSignal: AbortSignal) {
49
+ const writer = new ReplayMp4Writer(filePath);
50
+ const reader = source.stream.getReader();
51
+ let stopping = false;
52
+ let warning: string | undefined;
53
+ let readyResolve!: () => void;
54
+ let readyReject!: (error: unknown) => void;
55
+ const ready = new Promise<void>((resolve, reject) => { readyResolve = resolve; readyReject = reject; });
56
+ void ready.catch(() => undefined);
57
+ let pump: Promise<void> = Promise.resolve();
58
+ let stopTask: Promise<ReplayVideo> | undefined;
59
+ const stop = () => stopTask ??= (async () => {
60
+ stopping = true;
61
+ await Promise.allSettled([reader.cancel(), source.close()]);
62
+ await pump;
63
+ try {
64
+ await writer.finish();
65
+ if (!await isFinalizedMp4(filePath)) throw new Error('录屏未生成完整 MP4');
66
+ return { filePath, fileName, startedAt: new Date(writer.startedAt).toISOString(), warning };
67
+ } catch (error) {
68
+ await writer.cancel().catch(() => undefined);
69
+ await rm(filePath, { force: true });
70
+ throw error;
71
+ }
72
+ })();
73
+ try {
74
+ await writer.start();
75
+ pump = (async () => {
76
+ try {
77
+ while (!stopping) {
78
+ const { done, value } = await reader.read();
79
+ if (done) {
80
+ if (!stopping) throw new Error(`录屏视频流提前结束 ${source.diagnostics()}`);
81
+ break;
82
+ }
83
+ await writer.add(value);
84
+ if (writer.startedAt) readyResolve();
85
+ }
86
+ } catch (error) {
87
+ warning = `录像可能不完整:${error instanceof Error ? error.message : String(error)}`;
88
+ readyReject(error);
89
+ void source.close();
90
+ } finally { writer.end(); reader.releaseLock(); }
91
+ })();
92
+ await waitForVideoTask(ready, startupSignal);
93
+ return { startedAt: new Date(writer.startedAt).toISOString(), stop };
94
+ } catch (error) {
95
+ await stop().catch(() => undefined);
96
+ await rm(filePath, { force: true });
97
+ throw error;
98
+ }
99
+ }
@@ -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 };
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,10 @@ 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>
412
421
  <button id="previous" type="button" title="上一帧">上一帧</button>
413
422
  <button id="play" type="button">播放</button>
414
423
  <button id="next" type="button" title="下一帧">下一帧</button>
@@ -430,6 +439,12 @@ function createReplayHtml(input: {
430
439
  const data = JSON.parse(document.querySelector('#replay-data').textContent || '{}');
431
440
  const visualChecks = Array.isArray(data.visualChecks) ? data.visualChecks : [];
432
441
  const reportStartedAt = Date.parse(data.startedAt) || 0;
442
+ const recording = document.querySelector('#recording');
443
+ const toggleRecording = document.querySelector('#toggle-recording');
444
+ const videoOffset = Math.max(0, (Date.parse(data.video?.startedAt) || reportStartedAt) - reportStartedAt);
445
+ let videoMode = Boolean(data.video);
446
+ let pendingVideoTime = 0;
447
+ if (data.video) { recording.src = data.video.url; toggleRecording.hidden = false; }
433
448
  const frames = (Array.isArray(data.frames) ? data.frames : []).map((frame) => ({
434
449
  ...frame,
435
450
  offsetMs: Math.max(0, (Date.parse(frame.capturedAt) || reportStartedAt) - reportStartedAt),
@@ -487,8 +502,9 @@ function createReplayHtml(input: {
487
502
  const frameChanged = nextFrame !== currentFrame;
488
503
  currentFrame = nextFrame;
489
504
  const frame = frames[currentFrame];
490
- elements.empty.hidden = Boolean(frame);
491
- elements.screen.hidden = !frame;
505
+ elements.empty.hidden = videoMode || Boolean(frame?.imageUrl);
506
+ elements.screen.hidden = videoMode || !frame?.imageUrl;
507
+ recording.hidden = !videoMode;
492
508
  if (frame) {
493
509
  if (frameChanged || elements.screen.src !== frame.imageUrl) elements.screen.src = frame.imageUrl;
494
510
  elements.title.textContent = frame.nodeLabel || '未命名节点';
@@ -507,7 +523,7 @@ function createReplayHtml(input: {
507
523
  }
508
524
  elements.previous.disabled = !frames.length || currentFrame <= 0;
509
525
  elements.next.disabled = !frames.length || currentFrame >= frames.length - 1;
510
- elements.play.disabled = frames.length < 2;
526
+ elements.play.disabled = !videoMode && frames.length < 2;
511
527
  elements.seek.value = String(Math.round(currentMs / durationMs * 1000));
512
528
  elements.clock.textContent = formatTime(currentMs) + ' / ' + formatTime(durationMs);
513
529
  elements.playhead.style.left = (currentMs / durationMs * 100) + '%';
@@ -516,14 +532,40 @@ function createReplayHtml(input: {
516
532
  if (scrollStep) document.querySelector('.frame-item.active')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
517
533
  };
518
534
  const pause = () => {
535
+ recording.pause();
519
536
  cancelAnimationFrame(animationFrame);
520
537
  animationFrame = 0;
521
538
  elements.play.textContent = '播放';
522
539
  };
523
540
  const seekTo = (milliseconds, scrollStep = true) => {
524
541
  currentMs = Math.max(0, Math.min(durationMs, milliseconds));
542
+ pendingVideoTime = Math.max(0, (currentMs - videoOffset) / 1000);
543
+ if (data.video && recording.readyState >= 1) recording.currentTime = Math.min(pendingVideoTime, recording.duration || pendingVideoTime);
525
544
  render(scrollStep);
526
545
  };
546
+ recording.addEventListener('loadedmetadata', () => { recording.currentTime = Math.min(pendingVideoTime, recording.duration); });
547
+ recording.addEventListener('timeupdate', () => {
548
+ if (!videoMode || recording.paused) return;
549
+ currentMs = recording.currentTime * 1000 + videoOffset;
550
+ render();
551
+ });
552
+ recording.addEventListener('seeked', () => {
553
+ if (!videoMode) return;
554
+ currentMs = recording.currentTime * 1000 + videoOffset;
555
+ render();
556
+ });
557
+ recording.addEventListener('play', () => { elements.play.textContent = '暂停'; });
558
+ recording.addEventListener('pause', () => { elements.play.textContent = '播放'; });
559
+ recording.addEventListener('error', () => {
560
+ pause(); videoMode = false; render();
561
+ toggleRecording.textContent = '视频不可用,请将 MP4 与报告放在一起';
562
+ toggleRecording.disabled = true;
563
+ });
564
+ toggleRecording.addEventListener('click', () => {
565
+ pause(); videoMode = !videoMode;
566
+ toggleRecording.textContent = videoMode ? '查看节点截图' : '查看回放视频';
567
+ seekTo(currentMs, false);
568
+ });
527
569
  const tickPlayback = (now) => {
528
570
  currentMs = Math.min(durationMs, now - playbackStartedAt);
529
571
  render();
@@ -605,7 +647,12 @@ function createReplayHtml(input: {
605
647
  article.append(title);
606
648
  const detail = document.createElement('dl');
607
649
  const region = check.region || {};
608
- const entries = [['结果', check.result === null ? '无法判定' : String(check.result)], ['配置', check.settings],
650
+ const match = check.templateMatch;
651
+ const entries = [
652
+ ...(match ? [['实际匹配结果', match.matched ? '匹配到模板' : '未匹配到模板'],
653
+ ['预期匹配结果', match.expected === 'present' ? '匹配到模板' : '未匹配到模板'],
654
+ ['匹配得分 / 严格度', (match.score * 100).toFixed(4) + '% / ' + (match.threshold * 100).toFixed(4) + '%']] : []),
655
+ [match ? '条件是否成立' : '结果', check.timedOut ? '观察超时(按节点超时配置处理)' : check.result === null ? '无法判定' : match ? (check.result ? '成立 → true 分支' : '不成立 → false 分支') : String(check.result)], ['配置', check.settings],
609
656
  ['区域', [region.x, region.y, region.width, region.height].join(', ')],
610
657
  ['采样', check.sampleCount + ' 帧'], ['耗时', check.durationMs + 'ms'], ['说明', check.message], ...Object.entries(check.metrics)];
611
658
  entries.forEach(([key, value]) => {
@@ -685,6 +732,11 @@ function createReplayHtml(input: {
685
732
  elements.previous.addEventListener('click', () => { pause(); seekTo(frames[Math.max(0, currentFrame - 1)]?.offsetMs || 0); });
686
733
  elements.next.addEventListener('click', () => { pause(); seekTo(frames[Math.min(frames.length - 1, currentFrame + 1)]?.offsetMs || durationMs); });
687
734
  elements.play.addEventListener('click', () => {
735
+ if (videoMode) {
736
+ if (!recording.paused) return pause();
737
+ recording.play().catch(() => { elements.caption.textContent = '视频无法播放,请检查 MP4 文件是否存在或使用其他浏览器'; });
738
+ return;
739
+ }
688
740
  if (animationFrame) return pause();
689
741
  if (currentMs >= durationMs) currentMs = 0;
690
742
  playbackStartedAt = performance.now() - currentMs;
@@ -719,6 +771,7 @@ function createReplayHtml(input: {
719
771
  }
720
772
 
721
773
  export async function createAppiumReplayReport(input: {
774
+ video?: ReplayVideo;
722
775
  script: AppiumRecordedScriptRecord;
723
776
  deviceId: string;
724
777
  success: boolean;
@@ -753,6 +806,7 @@ export async function createAppiumReplayReport(input: {
753
806
  const visualCheckByStepId = new Map(visualChecks.map((check) => [check.nodeId, check]));
754
807
  const visualFailureCount = visualChecks.filter((check) => check.status === 'failed').length;
755
808
  const html = createReplayHtml({
809
+ video: input.video ? { startedAt: input.video.startedAt, url: relative(outputDir, input.video.filePath).split(/[/\\\\]/).map(encodeURIComponent).join('/') } : undefined,
756
810
  script: input.script,
757
811
  deviceId: input.deviceId,
758
812
  resultText,
@@ -99,6 +99,7 @@ export type AppiumRecordedStepRecord = {
99
99
  returns?: ScriptReturn[];
100
100
  keyCode?: number;
101
101
  timeoutMs?: number;
102
+ timeoutBranch?: 'stop' | 'yes' | 'no';
102
103
  longPressMode?: 'element' | 'coordinates';
103
104
  breakLoopTargetId?: string;
104
105
  loop?: {
@@ -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,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
+ }