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.
- package/CHANGELOG.md +13 -0
- package/README.md +13 -0
- package/package.json +12 -7
- package/server/appium-recorder/ai-recognition.ts +4 -2
- package/server/appium-recorder/appium-runner.ts +90 -37
- package/server/appium-recorder/condition-timeout.ts +4 -0
- package/server/appium-recorder/image-check.ts +16 -2
- package/server/appium-recorder/replay-mp4.ts +72 -0
- package/server/appium-recorder/replay-video.ts +99 -0
- package/server/appium-recorder/report.ts +60 -6
- package/server/appium-recorder/repository.ts +1 -0
- package/server/appium-recorder/routes.ts +13 -17
- package/server/appium-recorder/run-history.ts +7 -1
- package/server/appium-recorder/scrcpy-recording-source.ts +80 -0
- package/server/appium-recorder/video-response.ts +36 -0
- package/src/appium-recorder/AppiumPage.vue +42 -98
- package/src/appium-recorder/RunHistoryDialog.vue +7 -2
- package/src/appium-recorder/action-descriptions.ts +1 -1
- package/src/appium-recorder/api.ts +3 -10
- package/src/appium-recorder/components/BranchTimeoutSettings.vue +23 -0
- package/src/appium-recorder/components/FlowNodeCard.vue +2 -3
- package/src/appium-recorder/components/FlowStepEditor.vue +6 -21
- package/src/appium-recorder/components/ImageCheckDialog.vue +38 -9
- 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 +6 -2
- package/src/appium-recorder/node-timeout.ts +1 -0
- package/src/appium-recorder/run-history.ts +1 -0
- package/src/appium-recorder/types.ts +1 -0
- package/src/components/config/AppiumConfigPanel.vue +0 -35
- 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
|
|
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,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { sendReplayVideo } from './video-response';
|
|
2
3
|
import { getPresetVariables, savePresetVariables } from './variable-store';
|
|
3
4
|
import type { TestVariable } from '../../src/appium-recorder/variables';
|
|
4
5
|
import { deleteRunHistory, getRunHistory, listRunHistory, saveRunHistory } from './run-history';
|
|
5
|
-
import { recognizeDeviceScreen } from './ai-recognition';
|
|
6
6
|
import { readImage, cropImage } from './image-check';
|
|
7
7
|
import { PNG } from 'pngjs';
|
|
8
8
|
import { adbScreenshotBase64 } from './screenshot';
|
|
@@ -200,20 +200,7 @@ export async function handleAppiumRecorderRequest(
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
if (pathname === '/api/appium-recorder/ai-recognition/test' && req.method === 'POST') {
|
|
203
|
-
|
|
204
|
-
const deviceId = parsed.deviceId?.trim() || selectedDeviceId;
|
|
205
|
-
if (!deviceId) throw new Error('请选择设备');
|
|
206
|
-
assertDeviceAllowed(deviceId);
|
|
207
|
-
if (isRemoteDeviceId(deviceId)) throw new Error('远程设备暂不支持 AI 识别测试');
|
|
208
|
-
if (replayingDevices.has(deviceId)) { sendJson(res, { message: '设备正在回放,请稍后测试' }, 409); return true; }
|
|
209
|
-
const controller = new AbortController();
|
|
210
|
-
const abort = () => controller.abort();
|
|
211
|
-
res.once('close', abort);
|
|
212
|
-
try {
|
|
213
|
-
sendJson(res, await recognizeDeviceScreen({ deviceId, prompt: parsed.prompt, timeoutMs: parsed.timeoutMs, signal: controller.signal }));
|
|
214
|
-
} finally {
|
|
215
|
-
res.off('close', abort);
|
|
216
|
-
}
|
|
203
|
+
sendJson(res, { message: 'AI 识别操作已移除,请使用图像判断或原生组件判断' }, 410);
|
|
217
204
|
return true;
|
|
218
205
|
}
|
|
219
206
|
|
|
@@ -345,6 +332,14 @@ export async function handleAppiumRecorderRequest(
|
|
|
345
332
|
return true;
|
|
346
333
|
}
|
|
347
334
|
|
|
335
|
+
const videoMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/history\/([^/]+)\/video$/);
|
|
336
|
+
if (videoMatch && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
337
|
+
const run = getRunHistory(decodeURIComponent(videoMatch[1]), decodeURIComponent(videoMatch[2]));
|
|
338
|
+
if (!run?.video) { sendJson(res, { error: '本次运行没有视频' }, 404); return true; }
|
|
339
|
+
assertDeviceAllowed(run.deviceId);
|
|
340
|
+
await sendReplayVideo(req, res, run.video.filePath);
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
348
343
|
const historyMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/history(?:\/([^/]+))?$/);
|
|
349
344
|
if (historyMatch) {
|
|
350
345
|
const scriptId = decodeURIComponent(historyMatch[1]);
|
|
@@ -362,12 +357,13 @@ export async function handleAppiumRecorderRequest(
|
|
|
362
357
|
}
|
|
363
358
|
const replayMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/replay$/);
|
|
364
359
|
if (replayMatch && req.method === 'POST') {
|
|
365
|
-
const parsed = await readBody<{ deviceId?: string; parameters?: TestVariable[] }>(req);
|
|
360
|
+
const parsed = await readBody<{ deviceId?: string; parameters?: TestVariable[]; recordVideo?: boolean }>(req);
|
|
366
361
|
const script = getAppiumRecordedScript(decodeURIComponent(replayMatch[1]));
|
|
367
362
|
if (!script) throw new Error('Appium 录制脚本不存在');
|
|
368
363
|
const deviceId = parsed.deviceId || selectedDeviceId;
|
|
369
364
|
assertDeviceAllowed(deviceId);
|
|
370
365
|
const streamOutput = req.headers.accept?.includes('application/x-ndjson') === true;
|
|
366
|
+
if (parsed.recordVideo === true && isRemoteDeviceId(deviceId)) throw new Error('远程设备暂不支持回放录屏,请在连接设备的主机本地运行');
|
|
371
367
|
if (streamOutput) {
|
|
372
368
|
res.statusCode = 200;
|
|
373
369
|
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
@@ -403,7 +399,7 @@ export async function handleAppiumRecorderRequest(
|
|
|
403
399
|
deviceId,
|
|
404
400
|
streamOutput ? (line) => sendStreamEvent(res, { type: 'log', line }) : undefined,
|
|
405
401
|
replayAbortController.signal,
|
|
406
|
-
{ parameters: parsed.parameters, globalVariables: getPresetVariables() },
|
|
402
|
+
{ parameters: parsed.parameters, globalVariables: getPresetVariables(), recordVideo: parsed.recordVideo === true },
|
|
407
403
|
);
|
|
408
404
|
try { saveRunHistory(result.history); }
|
|
409
405
|
catch { result.output += '\n历史记录保存失败'; }
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createId, querySql, runSql, sqlJson, sqlString } from '../storage/sqlite';
|
|
2
|
+
import { rmSync } from 'node:fs';
|
|
3
|
+
import { basename } from 'node:path';
|
|
2
4
|
import type { RunDetail, RunSummary } from '../../src/appium-recorder/run-history';
|
|
3
5
|
|
|
4
6
|
function ensureTable() {
|
|
@@ -29,6 +31,10 @@ export function getRunHistory(scriptId: string, id: string): RunDetail | null {
|
|
|
29
31
|
|
|
30
32
|
export function deleteRunHistory(scriptId: string, id: string) {
|
|
31
33
|
ensureTable();
|
|
32
|
-
|
|
34
|
+
const video = getRunHistory(scriptId, id)?.video;
|
|
35
|
+
// 只清理服务端生成并关联到该记录的录像,不接受客户端文件路径。
|
|
36
|
+
if (video && basename(video.filePath) === video.fileName && /^replay-[\d]+-[\da-f-]+\.mp4$/.test(video.fileName)) {
|
|
37
|
+
rmSync(video.filePath, { force: true });
|
|
38
|
+
}
|
|
33
39
|
runSql(`PRAGMA secure_delete=ON; DELETE FROM appium_run_history WHERE script_id=${sqlString(scriptId)} AND id=${sqlString(id)};`);
|
|
34
40
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { AdbServerClient, type Adb } from '@yume-chan/adb';
|
|
7
|
+
import { AdbServerNodeTcpConnector } from '@yume-chan/adb-server-node-tcp';
|
|
8
|
+
import { AdbScrcpyClient, AdbScrcpyOptions3_3_3 } from '@yume-chan/adb-scrcpy';
|
|
9
|
+
import { ScrcpyVideoCodecId } from '@yume-chan/scrcpy';
|
|
10
|
+
import { getAdbCommand } from '../android-sdk';
|
|
11
|
+
|
|
12
|
+
export function waitForVideoTask<T>(task: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const abort = () => reject(signal.reason);
|
|
15
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
16
|
+
task.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
17
|
+
if (signal.aborted) abort();
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function adbCommand(args: string[], signal?: AbortSignal) {
|
|
22
|
+
return new Promise<void>((resolve, reject) => {
|
|
23
|
+
execFile(getAdbCommand(), args, { timeout: 10000, signal, windowsHide: true }, error => error ? reject(error) : resolve());
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function openRecordingSource(deviceId: string, signal: AbortSignal) {
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const bin = join(dirname(require.resolve('@midscene/android-playground/package.json')), 'bin');
|
|
30
|
+
const version = (await readFile(join(bin, 'scrcpy-server.version'), 'utf8')).trim();
|
|
31
|
+
if (version !== 'v3.3.3') throw new Error(`内置 scrcpy 服务端版本不兼容:${version}`);
|
|
32
|
+
const remotePath = `/data/local/tmp/midscene-recording-${randomUUID()}.jar`;
|
|
33
|
+
let adb: Adb | undefined;
|
|
34
|
+
let client: Awaited<ReturnType<typeof AdbScrcpyClient.start>> | undefined;
|
|
35
|
+
let closing = false;
|
|
36
|
+
let logs = '';
|
|
37
|
+
let closeTask: Promise<void> | undefined;
|
|
38
|
+
const close = () => closeTask ??= (async () => {
|
|
39
|
+
closing = true;
|
|
40
|
+
await waitForVideoTask(Promise.allSettled([client?.close(), adb?.close()]), AbortSignal.timeout(3000)).catch(() => undefined);
|
|
41
|
+
await adbCommand(['-s', deviceId, 'shell', 'rm', '-f', remotePath]).catch(() => undefined);
|
|
42
|
+
})();
|
|
43
|
+
try {
|
|
44
|
+
await adbCommand(['start-server'], signal);
|
|
45
|
+
await adbCommand(['-s', deviceId, 'push', join(bin, 'scrcpy-server'), remotePath], signal);
|
|
46
|
+
const server = new AdbServerClient(new AdbServerNodeTcpConnector({ host: '127.0.0.1', port: 5037 }));
|
|
47
|
+
adb = await waitForVideoTask(server.createAdb({ serial: deviceId }).then(connection => {
|
|
48
|
+
if (closing || signal.aborted) { void connection.close(); throw signal.reason || new Error('录屏已取消'); }
|
|
49
|
+
return connection;
|
|
50
|
+
}), signal);
|
|
51
|
+
// 独立 socket ID 和上传路径,停止录制不会关闭正在使用的设备预览。
|
|
52
|
+
const options = new AdbScrcpyOptions3_3_3({
|
|
53
|
+
scid: Math.floor(Math.random() * 0x7fffffff).toString(16).padStart(8, '0'),
|
|
54
|
+
audio: false, control: false, videoCodec: 'h264', sendFrameMeta: true,
|
|
55
|
+
maxSize: 1280, maxFps: 15, videoBitRate: 2000000,
|
|
56
|
+
captureOrientation: '@', videoCodecOptions: 'profile=1,max-bframes=0,i-frame-interval=2',
|
|
57
|
+
});
|
|
58
|
+
client = await waitForVideoTask(AdbScrcpyClient.start(adb, remotePath, options).then(value => {
|
|
59
|
+
if (closing || signal.aborted) { void value.close(); throw signal.reason || new Error('录屏已取消'); }
|
|
60
|
+
return value;
|
|
61
|
+
}), signal);
|
|
62
|
+
// 持续消费服务端输出,避免输出管道阻塞编码进程。
|
|
63
|
+
const outputReader = client.output.getReader();
|
|
64
|
+
void (async () => {
|
|
65
|
+
try {
|
|
66
|
+
while (true) {
|
|
67
|
+
const { done, value } = await outputReader.read();
|
|
68
|
+
if (done) break;
|
|
69
|
+
logs = `${logs}\n${value}`.slice(-4000);
|
|
70
|
+
}
|
|
71
|
+
} finally { outputReader.releaseLock(); }
|
|
72
|
+
})().catch(() => undefined);
|
|
73
|
+
const video = await waitForVideoTask(client.videoStream!, signal);
|
|
74
|
+
if (video.metadata.codec !== ScrcpyVideoCodecId.H264) throw new Error('录屏需要 H.264 视频流');
|
|
75
|
+
return { stream: video.stream, close, diagnostics: () => logs };
|
|
76
|
+
} catch (error) {
|
|
77
|
+
await close();
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
|
|
4
|
+
// 浏览器按字节加载 MP4;支持 Range 才能直接跳转到录像中间。
|
|
5
|
+
export async function sendReplayVideo(req: IncomingMessage, res: ServerResponse, path: string) {
|
|
6
|
+
const file = await open(path, 'r').catch(() => null);
|
|
7
|
+
if (!file) { res.statusCode = 404; res.end('视频文件不存在'); return; }
|
|
8
|
+
let streaming = false;
|
|
9
|
+
try {
|
|
10
|
+
const { size } = await file.stat();
|
|
11
|
+
let start = 0, end = size - 1;
|
|
12
|
+
const range = req.headers.range;
|
|
13
|
+
if (range) {
|
|
14
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
15
|
+
if (match && (match[1] || match[2])) {
|
|
16
|
+
start = match[1] ? Number(match[1]) : Math.max(0, size - Number(match[2]));
|
|
17
|
+
end = match[1] && match[2] ? Math.min(Number(match[2]), size - 1) : size - 1;
|
|
18
|
+
} else start = size;
|
|
19
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= size) {
|
|
20
|
+
res.writeHead(416, { 'Content-Range': `bytes */${size}` }); res.end(); return;
|
|
21
|
+
}
|
|
22
|
+
res.statusCode = 206;
|
|
23
|
+
res.setHeader('Content-Range', `bytes ${start}-${end}/${size}`);
|
|
24
|
+
}
|
|
25
|
+
res.setHeader('Content-Type', 'video/mp4');
|
|
26
|
+
res.setHeader('Accept-Ranges', 'bytes');
|
|
27
|
+
res.setHeader('Cache-Control', 'private, no-store');
|
|
28
|
+
res.setHeader('Content-Length', String(Math.max(0, end - start + 1)));
|
|
29
|
+
if (req.method === 'HEAD' || !size) { res.end(); return; }
|
|
30
|
+
const stream = file.createReadStream({ start, end });
|
|
31
|
+
streaming = true;
|
|
32
|
+
res.once('close', () => stream.destroy());
|
|
33
|
+
stream.once('error', () => res.destroy());
|
|
34
|
+
stream.pipe(res);
|
|
35
|
+
} finally { if (!streaming) await file.close(); }
|
|
36
|
+
}
|