android-midscene-automation 0.1.38 → 0.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +8 -0
- package/package.json +12 -7
- package/server/appium-recorder/appium-runner.ts +50 -16
- package/server/appium-recorder/image-check.ts +10 -0
- package/server/appium-recorder/replay-mp4.ts +72 -0
- package/server/appium-recorder/replay-video.ts +99 -0
- package/server/appium-recorder/report.ts +60 -6
- package/server/appium-recorder/routes.ts +13 -17
- package/server/appium-recorder/run-history.ts +7 -1
- package/server/appium-recorder/scrcpy-recording-source.ts +80 -0
- package/server/appium-recorder/video-response.ts +36 -0
- package/src/appium-recorder/AppiumPage.vue +24 -41
- package/src/appium-recorder/RunHistoryDialog.vue +7 -2
- package/src/appium-recorder/action-descriptions.ts +1 -1
- package/src/appium-recorder/api.ts +3 -10
- package/src/appium-recorder/components/FlowNodeCard.vue +2 -3
- package/src/appium-recorder/components/FlowStepEditor.vue +0 -6
- package/src/appium-recorder/components/ImageCheckDialog.vue +28 -5
- package/src/appium-recorder/components/NestedConditionBranches.vue +0 -1
- package/src/appium-recorder/components/RecordedSteps.vue +0 -1
- package/src/appium-recorder/flow-graph.ts +2 -2
- package/src/appium-recorder/flow-labels.ts +2 -2
- package/src/appium-recorder/image-check.ts +3 -1
- package/src/appium-recorder/run-history.ts +1 -0
- package/src/components/config/AppiumConfigPanel.vue +0 -35
- package/src/appium-recorder/components/AiRecognitionTestDialog.vue +0 -71
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
# 更新记录
|
|
2
|
+
## v0.1.39
|
|
3
|
+
- Midscene 相关模块统一升级至 1.12.7,
|
|
4
|
+
- scrcpy 视频帧完整性修复:可靠传输视频包、修正关键帧标记,并在缓冲溢出后等待完整关键帧恢复,降低设备预览花屏风险。
|
|
5
|
+
- 回放按钮右侧下拉设置新增默认关闭的“录制回放视频”,使用内置服务端后台接收视频流并封装 MP4,无需安装 scrcpy 或 FFmpeg;远程设备暂不支持。
|
|
6
|
+
- 图像模板判断改为“预期匹配结果”,匹配严格度以百分比配置,100% 时提示细微像素差异风险;节点显示预期结果,日志和 HTML 报告分别展示实际匹配、预期匹配、得分与条件结论,保留原检测算法及已有阈值。
|
|
7
|
+
- 移除 AI 识别操作、测试入口及 Appium 参数中的 AI 模型配置,保留流程背景色设置;旧脚本中的 AI 节点提示替换,回放前阻止执行,不自动删除或改变分支。
|
|
8
|
+
|
|
9
|
+
|
|
2
10
|
## v0.1.38
|
|
3
11
|
- 移除节点类型切换;新节点及未配置的判断等待超时统一为 3000ms,已有自定义时间和延时、长按等操作时长保持不变。
|
|
4
12
|
- 取消 Activity 不匹配时的脚本编辑锁定,不在脚本绑定页面也可编辑节点;页面差异仅作提示,保留录制操作及回放期间的编辑保护。
|
package/README.md
CHANGED
|
@@ -1229,6 +1229,14 @@ Linux: ~/.local/share/android-midscene-automation
|
|
|
1229
1229
|
|
|
1230
1230
|
# 项目更新记录
|
|
1231
1231
|
|
|
1232
|
+
## v0.1.39
|
|
1233
|
+
- Midscene 相关模块统一升级至 1.12.7,
|
|
1234
|
+
- scrcpy 视频帧完整性修复:可靠传输视频包、修正关键帧标记,并在缓冲溢出后等待完整关键帧恢复,降低设备预览花屏风险。
|
|
1235
|
+
- 回放按钮右侧下拉设置新增默认关闭的“录制回放视频”,使用内置服务端后台接收视频流并封装 MP4,无需安装 scrcpy 或 FFmpeg;远程设备暂不支持。
|
|
1236
|
+
- 图像模板判断改为“预期匹配结果”,匹配严格度以百分比配置,100% 时提示细微像素差异风险;节点显示预期结果,日志和 HTML 报告分别展示实际匹配、预期匹配、得分与条件结论,保留原检测算法及已有阈值。
|
|
1237
|
+
- 移除 AI 识别操作、测试入口及 Appium 参数中的 AI 模型配置,保留流程背景色设置;旧脚本中的 AI 节点提示替换,回放前阻止执行,不自动删除或改变分支。
|
|
1238
|
+
|
|
1239
|
+
|
|
1232
1240
|
## v0.1.38
|
|
1233
1241
|
- 移除节点类型切换;新节点及未配置的判断等待超时统一为 3000ms,已有自定义时间和延时、长按等操作时长保持不变。
|
|
1234
1242
|
- 取消 Activity 不匹配时的脚本编辑锁定,不在脚本绑定页面也可编辑节点;页面差异仅作提示,保留录制操作及回放期间的编辑保护。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "android-midscene-automation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.39",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/oooooooko/android-midscene-automation.git"
|
|
@@ -36,14 +36,19 @@
|
|
|
36
36
|
"test:e2e": "playwright test"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
+
"@yume-chan/adb": "2.6.3",
|
|
40
|
+
"@yume-chan/adb-scrcpy": "2.3.2",
|
|
41
|
+
"@yume-chan/adb-server-node-tcp": "2.5.2",
|
|
42
|
+
"@yume-chan/scrcpy": "2.3.0",
|
|
43
|
+
"mediabunny": "1.58.0",
|
|
39
44
|
"@dagrejs/dagre": "1.1.5",
|
|
40
45
|
"@element-plus/icons-vue": "2.3.2",
|
|
41
|
-
"@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.
|
|
42
|
-
"@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.
|
|
43
|
-
"@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.
|
|
44
|
-
"@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.
|
|
45
|
-
"@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.
|
|
46
|
-
"@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.
|
|
46
|
+
"@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.7.tgz",
|
|
47
|
+
"@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.7.tgz",
|
|
48
|
+
"@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.7.tgz",
|
|
49
|
+
"@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.7.tgz",
|
|
50
|
+
"@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.7.tgz",
|
|
51
|
+
"@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.7.tgz",
|
|
47
52
|
"@techstark/opencv-js": "5.0.0-release.1",
|
|
48
53
|
"@vitejs/plugin-vue": "6.0.7",
|
|
49
54
|
"@vue-flow/core": "1.48.2",
|
|
@@ -6,13 +6,13 @@ import { captureHistoryFrames, readAppVersion } from './history-capture';
|
|
|
6
6
|
import { BoundedLoopTraversal } from './bounded-loop';
|
|
7
7
|
import { validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
|
|
8
8
|
import { adbScreenshotBase64 } from './screenshot';
|
|
9
|
-
import { recognizeDeviceScreen } from './ai-recognition';
|
|
10
9
|
import { checkImage } from './image-check';
|
|
11
10
|
import { IMAGE_CHECK_MODES, type ImageCheckResult } from '../../src/appium-recorder/image-check';
|
|
12
11
|
import { formatStageLog } from '../../src/appium-recorder/stage-log';
|
|
13
12
|
import { openGalleryOnDevice } from './open-gallery';
|
|
14
13
|
import { stopAppOnDevice } from './stop-app';
|
|
15
14
|
import { DEFAULT_NODE_TIMEOUT_MS } from '../../src/appium-recorder/node-timeout';
|
|
15
|
+
import { startReplayVideo, type ReplayVideo } from './replay-video';
|
|
16
16
|
import { ConditionTimeoutError, AppiumServiceError } from './condition-timeout';
|
|
17
17
|
import { AppiumRequestTimeoutError, timedAppiumFetch, withElementDeadline } from './request-timeout';
|
|
18
18
|
import { textClickSelector } from '../../src/appium-recorder/text-click';
|
|
@@ -353,10 +353,11 @@ async function captureReplayFrame(
|
|
|
353
353
|
) {
|
|
354
354
|
const context = replayContext.getStore();
|
|
355
355
|
if (!context || !context.deviceId) return;
|
|
356
|
+
const capturedAt = new Date().toISOString();
|
|
356
357
|
// 执行记录独立于截图,设备截图失败不能让失败节点从统计中消失。
|
|
357
358
|
context.historyEvents.push({ sequence: context.historyEvents.length + 1, scriptName, nodeId: step.id,
|
|
358
359
|
nodeNumber, nodeLabel: step.label, nodeType: step.type, note: step.note || '',
|
|
359
|
-
selector: replayFrameSelector(step), phase, status, capturedAt
|
|
360
|
+
selector: replayFrameSelector(step), phase, status, capturedAt, imageBase64: '' });
|
|
360
361
|
const appendFrame = (imageBase64: string) => {
|
|
361
362
|
context.frames.push({
|
|
362
363
|
sequence: context.frames.length + 1,
|
|
@@ -370,7 +371,7 @@ async function captureReplayFrame(
|
|
|
370
371
|
selector: replayFrameSelector(step),
|
|
371
372
|
phase,
|
|
372
373
|
status,
|
|
373
|
-
capturedAt
|
|
374
|
+
capturedAt,
|
|
374
375
|
imageBase64,
|
|
375
376
|
});
|
|
376
377
|
};
|
|
@@ -386,6 +387,8 @@ async function captureReplayFrame(
|
|
|
386
387
|
appendFrame(imageBase64 || await adbScreenshotBase64(context.deviceId));
|
|
387
388
|
} catch (error) {
|
|
388
389
|
context.appiumLog(`[节点 ${nodeNumber}] 截图失败:${errorDetail(error)}`);
|
|
390
|
+
// 截图失败仍保留时间锚点,报告可定位到该节点的录像。
|
|
391
|
+
appendFrame('');
|
|
389
392
|
}
|
|
390
393
|
}
|
|
391
394
|
|
|
@@ -1258,13 +1261,7 @@ async function replayFlowSteps(
|
|
|
1258
1261
|
}
|
|
1259
1262
|
continue;
|
|
1260
1263
|
}
|
|
1261
|
-
|
|
1262
|
-
const beforeFrame = replayContext.getStore()?.frames.at(-1);
|
|
1263
|
-
const recognition = step.type === 'aiRecognition' ? await recognizeDeviceScreen({
|
|
1264
|
-
deviceId, prompt: step.value, timeoutMs: step.timeoutMs,
|
|
1265
|
-
signal: replayContext.getStore()?.signal,
|
|
1266
|
-
imageBase64: beforeFrame?.nodeId === step.id && beforeFrame.phase === 'before' ? beforeFrame.imageBase64 : undefined,
|
|
1267
|
-
}) : undefined;
|
|
1264
|
+
if (step.type === 'aiRecognition') throw new Error('AI 识别操作已移除,请替换此节点后回放');
|
|
1268
1265
|
let imageResult: ImageCheckResult | undefined;
|
|
1269
1266
|
if (step.type === 'imageCheck') {
|
|
1270
1267
|
const resolved = resolveVariableStep(step);
|
|
@@ -1273,14 +1270,22 @@ async function replayFlowSteps(
|
|
|
1273
1270
|
resolveRegion: () => imageCheckRegion(sessionId, resolved), wait,
|
|
1274
1271
|
signal: replayContext.getStore()?.signal, saveImages: !variableContext.getStore()?.privacy.enabled });
|
|
1275
1272
|
const config = resolved.imageCheck;
|
|
1276
|
-
const settings = config ?
|
|
1273
|
+
const settings = config ? [
|
|
1274
|
+
IMAGE_CHECK_MODES[config.mode],
|
|
1275
|
+
config.mode === 'template' ? `预期匹配结果:${config.expectation === 'present' ? '匹配到模板' : '未匹配到模板'}` : '',
|
|
1276
|
+
['template', 'state'].includes(config.mode) ? `匹配严格度 ${config.threshold * 100}%` : '',
|
|
1277
|
+
config.mode === 'state' ? `最小得分差 ${config.minScoreGap}` : '',
|
|
1278
|
+
['black', 'color', 'change'].includes(config.mode) ? `RGB/亮度容差 ${config.tolerance};像素占比 ${config.ratio}%` : '',
|
|
1279
|
+
config.mode === 'color' ? `目标色 ${config.color}` : '',
|
|
1280
|
+
config.mode === 'change' ? `预期:${config.expectation === 'present' ? '画面有变化' : '持续无明显变化'}` : '',
|
|
1281
|
+
config.mode !== 'state' ? `观察 ${config.durationMs}ms;间隔 ${config.intervalMs}ms;连续 ${config.consecutive} 帧` : '',
|
|
1282
|
+
].filter(Boolean).join(';') : '缺少配置';
|
|
1277
1283
|
replayContext.getStore()?.imageChecks.push({ ...imageResult, nodeId: step.id, nodeNumber: index + 1, nodeLabel: frameStep.label, scriptName: options.scriptName || '', settings });
|
|
1278
1284
|
lines.push(`[节点 ${index + 1}] 图像判断:${imageResult.result === null ? '无法判定' : imageResult.result},${imageResult.message};${settings};采样 ${imageResult.sampleCount} 帧,耗时 ${imageResult.durationMs}ms;指标 ${JSON.stringify(imageResult.metrics)}`);
|
|
1279
1285
|
if (imageResult.result === null) throw new Error(imageResult.message);
|
|
1280
1286
|
if (imageResult.timedOut) throw new ConditionTimeoutError(imageResult.message);
|
|
1281
1287
|
}
|
|
1282
|
-
const matched = imageResult ? imageResult.result! :
|
|
1283
|
-
if (recognition) lines.push(`[节点 ${index + 1}] AI 识别:${recognition.result},耗时 ${recognition.durationMs}ms${recognition.reason ? `,依据:${recognition.reason}` : ''}`);
|
|
1288
|
+
const matched = imageResult ? imageResult.result! : await evaluateCondition(sessionId, deviceId, step);
|
|
1284
1289
|
const branchTargetId = matched ? step.flow?.yesTargetId : step.flow?.noTargetId;
|
|
1285
1290
|
const next = traversal.branch(step, matched);
|
|
1286
1291
|
const targetId = branchTargetId || (next === undefined ? '' : steps[next]?.id) || '';
|
|
@@ -1292,8 +1297,7 @@ async function replayFlowSteps(
|
|
|
1292
1297
|
index + 1,
|
|
1293
1298
|
options.scriptName || '',
|
|
1294
1299
|
'after',
|
|
1295
|
-
`判断:${branchLabel}
|
|
1296
|
-
recognition?.imageBase64,
|
|
1300
|
+
`判断:${branchLabel}`,
|
|
1297
1301
|
);
|
|
1298
1302
|
index = next;
|
|
1299
1303
|
} catch (error) {
|
|
@@ -1432,7 +1436,7 @@ export async function replayAppiumScript(
|
|
|
1432
1436
|
deviceId: string,
|
|
1433
1437
|
onOutput?: (line: string) => void,
|
|
1434
1438
|
signal?: AbortSignal,
|
|
1435
|
-
runOptions: { parameters?: TestVariable[]; globalVariables?: TestVariable[]; linkedScripts?: AppiumRecordedScriptRecord[] } = {},
|
|
1439
|
+
runOptions: { recordVideo?: boolean; parameters?: TestVariable[]; globalVariables?: TestVariable[]; linkedScripts?: AppiumRecordedScriptRecord[] } = {},
|
|
1436
1440
|
) {
|
|
1437
1441
|
const targetDeviceId = deviceId || script.deviceId;
|
|
1438
1442
|
if (!targetDeviceId) throw new Error('未检测到可用设备');
|
|
@@ -1440,6 +1444,12 @@ export async function replayAppiumScript(
|
|
|
1440
1444
|
|
|
1441
1445
|
const scope = new VariableScope(runOptions.globalVariables ?? getPresetVariables(), script.variables || []);
|
|
1442
1446
|
const scripts = new Map((runOptions.linkedScripts ?? linkedScriptSnapshot(script)).map(item => [item.id, item]));
|
|
1447
|
+
// 旧节点保留图结构供用户替换,但必须在任何设备操作之前阻止回放。
|
|
1448
|
+
for (const candidate of [script, ...scripts.values()]) {
|
|
1449
|
+
if (candidate.steps.some(step => step.type === 'aiRecognition')) {
|
|
1450
|
+
throw new Error(`脚本“${candidate.name}”包含已移除的 AI 识别操作,请改用图像判断或原生组件判断后回放`);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1443
1453
|
for (const item of validateVariables(runOptions.parameters)) scope.set(item);
|
|
1444
1454
|
// 提前登记子脚本敏感预设,避免进入子脚本前的原始日志或截图泄露。
|
|
1445
1455
|
const visited = new Set<string>();
|
|
@@ -1492,6 +1502,8 @@ export async function replayAppiumScript(
|
|
|
1492
1502
|
let sessionId = '';
|
|
1493
1503
|
let success = false;
|
|
1494
1504
|
let stopped = false;
|
|
1505
|
+
let recording: Awaited<ReturnType<typeof startReplayVideo>> | undefined;
|
|
1506
|
+
let video: ReplayVideo | undefined;
|
|
1495
1507
|
let managedAppium: Awaited<ReturnType<typeof startManagedAppiumServer>> | null = null;
|
|
1496
1508
|
try {
|
|
1497
1509
|
validateLoopSteps(script.steps);
|
|
@@ -1516,6 +1528,12 @@ export async function replayAppiumScript(
|
|
|
1516
1528
|
sessionId = await createSession(targetDeviceId);
|
|
1517
1529
|
}
|
|
1518
1530
|
lines.push(`Appium session 已创建:${sessionId}`);
|
|
1531
|
+
if (runOptions.recordVideo) {
|
|
1532
|
+
if (scope.privacy.enabled) throw new Error('本次运行包含敏感变量,为避免泄露已禁止录屏,请关闭录制回放视频');
|
|
1533
|
+
lines.push('正在启动后台录屏...');
|
|
1534
|
+
recording = await startReplayVideo(targetDeviceId, signal);
|
|
1535
|
+
lines.push('后台录屏已启动(MP4 / H.264 / 15fps / 2Mbps,无音频)');
|
|
1536
|
+
}
|
|
1519
1537
|
if (hasFlowSteps(script.steps)) lines.push('按流程图路径回放...');
|
|
1520
1538
|
await replayScriptSteps(sessionId, targetDeviceId, script.steps, lines, [script.id], {
|
|
1521
1539
|
scriptName: script.name,
|
|
@@ -1543,6 +1561,19 @@ export async function replayAppiumScript(
|
|
|
1543
1561
|
lines.push(`回放终止:${detail}`);
|
|
1544
1562
|
}
|
|
1545
1563
|
} finally {
|
|
1564
|
+
if (recording) {
|
|
1565
|
+
try {
|
|
1566
|
+
video = await recording.stop();
|
|
1567
|
+
if (scope.privacy.enabled) {
|
|
1568
|
+
await import('node:fs/promises').then(fs => fs.rm(video!.filePath, { force: true }));
|
|
1569
|
+
video = undefined;
|
|
1570
|
+
lines.push('敏感变量保护:已删除回放视频');
|
|
1571
|
+
} else {
|
|
1572
|
+
lines.push(`回放视频已保存:${video.filePath}`);
|
|
1573
|
+
if (video.warning) lines.push(`录屏警告:${video.warning}`);
|
|
1574
|
+
}
|
|
1575
|
+
} catch (error) { lines.push(`回放视频保存失败:${errorDetail(error)}`); }
|
|
1576
|
+
}
|
|
1546
1577
|
let managedAppiumStopped = false;
|
|
1547
1578
|
if (signal?.aborted && managedAppium) {
|
|
1548
1579
|
await managedAppium.stop();
|
|
@@ -1581,6 +1612,7 @@ export async function replayAppiumScript(
|
|
|
1581
1612
|
success,
|
|
1582
1613
|
stopped,
|
|
1583
1614
|
output: scope.redact(lines.join('\n')),
|
|
1615
|
+
video,
|
|
1584
1616
|
startedAt,
|
|
1585
1617
|
completedAt,
|
|
1586
1618
|
frames: safeFrames,
|
|
@@ -1605,9 +1637,11 @@ export async function replayAppiumScript(
|
|
|
1605
1637
|
reportId,
|
|
1606
1638
|
logPath,
|
|
1607
1639
|
htmlReportPath,
|
|
1640
|
+
videoPath: video?.filePath,
|
|
1608
1641
|
softFailureCount: context.softFailureCount,
|
|
1609
1642
|
history: {
|
|
1610
1643
|
scriptId: script.id, scriptName: script.name, appPackage: script.appPackage,
|
|
1644
|
+
video,
|
|
1611
1645
|
appVersion, deviceId: targetDeviceId, startedAt: startedAt.toISOString(),
|
|
1612
1646
|
durationMs: completedAt.getTime() - startedAt.getTime(),
|
|
1613
1647
|
status: stopped ? 'stopped' as const : success ? 'passed' as const : 'failed' as const,
|
|
@@ -128,6 +128,10 @@ export async function checkImage(input: {
|
|
|
128
128
|
if (!baseline) { baseline = current; observedAt = now(); keep('首帧', current); }
|
|
129
129
|
const measurement = await measure(current, baseline, templates, config);
|
|
130
130
|
result.metrics = { ...measurement.metrics };
|
|
131
|
+
if (config.mode === 'template') {
|
|
132
|
+
const score = result.metrics['模板得分']!;
|
|
133
|
+
result.templateMatch = { matched: score >= config.threshold, expected: config.expectation, score, threshold: config.threshold };
|
|
134
|
+
}
|
|
131
135
|
input.signal?.throwIfAborted();
|
|
132
136
|
if (config.mode === 'state') {
|
|
133
137
|
if (measurement.matched === null) throw new Error('双模板均未达到阈值或得分过于接近');
|
|
@@ -153,6 +157,12 @@ export async function checkImage(input: {
|
|
|
153
157
|
}
|
|
154
158
|
if (last) keep('判定帧', last);
|
|
155
159
|
result.message = result.timedOut ? '观察超时,未在规定时间内满足条件' : result.result ? '条件成立' : '条件不成立';
|
|
160
|
+
if (result.templateMatch) {
|
|
161
|
+
const match = result.templateMatch;
|
|
162
|
+
const actual = match.matched ? '匹配到模板' : '未匹配到模板';
|
|
163
|
+
const expected = match.expected === 'present' ? '匹配到模板' : '未匹配到模板';
|
|
164
|
+
result.message = `实际${actual}:得分 ${(match.score * 100).toFixed(4)}%,${match.matched ? '达到' : '低于'}匹配严格度 ${(match.threshold * 100).toFixed(4)}%;预期${expected};${result.message}${result.timedOut ? '' : ` → ${result.result} 分支`}`;
|
|
165
|
+
}
|
|
156
166
|
} catch (error) {
|
|
157
167
|
if (input.signal?.aborted) throw error;
|
|
158
168
|
if (error instanceof AppiumRequestTimeoutError || error instanceof AppiumServiceError) throw error;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { EncodedPacket, EncodedVideoPacketSource, FilePathTarget, Mp4OutputFormat, Output } from 'mediabunny';
|
|
2
|
+
import { h264ParseConfiguration, type ScrcpyMediaStreamPacket } from '@yume-chan/scrcpy';
|
|
3
|
+
|
|
4
|
+
// 仅封装 H.264,不解码或重新编码;最多保留最后一帧以计算静止画面的持续时间。
|
|
5
|
+
export class ReplayMp4Writer {
|
|
6
|
+
private source = new EncodedVideoPacketSource('avc');
|
|
7
|
+
private output: Output;
|
|
8
|
+
private config?: ReturnType<typeof h264ParseConfiguration>;
|
|
9
|
+
private configBytes?: Uint8Array;
|
|
10
|
+
private origin?: bigint;
|
|
11
|
+
private pending?: { data: Uint8Array; key: boolean; timestamp: number };
|
|
12
|
+
private first = true;
|
|
13
|
+
startedAt = 0;
|
|
14
|
+
private startedMonotonic = 0;
|
|
15
|
+
private endedMonotonic?: number;
|
|
16
|
+
|
|
17
|
+
constructor(path: string) {
|
|
18
|
+
this.output = new Output({ target: new FilePathTarget(path, { chunkSize: 1024 * 1024 }), format: new Mp4OutputFormat({ fastStart: 'fragmented' }) });
|
|
19
|
+
this.output.addVideoTrack(this.source);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async start() { await this.output.start(); }
|
|
23
|
+
|
|
24
|
+
async add(packet: ScrcpyMediaStreamPacket) {
|
|
25
|
+
if (packet.type === 'configuration') {
|
|
26
|
+
const config = h264ParseConfiguration(packet.data);
|
|
27
|
+
if (this.configBytes && !Buffer.from(this.configBytes).equals(Buffer.from(packet.data))) {
|
|
28
|
+
throw new Error('录屏编码配置发生变化,已保留变化前的视频');
|
|
29
|
+
}
|
|
30
|
+
this.config = config;
|
|
31
|
+
this.configBytes = packet.data.slice();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!this.config || !this.configBytes) throw new Error('录屏缺少 H.264 配置信息');
|
|
35
|
+
if (packet.pts === undefined) throw new Error('录屏视频包缺少时间戳');
|
|
36
|
+
if (typeof packet.keyframe !== 'boolean') throw new Error('录屏视频包缺少关键帧标记');
|
|
37
|
+
if (this.origin === undefined) {
|
|
38
|
+
if (!packet.keyframe) return;
|
|
39
|
+
this.origin = packet.pts;
|
|
40
|
+
this.startedAt = Date.now();
|
|
41
|
+
this.startedMonotonic = performance.now();
|
|
42
|
+
}
|
|
43
|
+
const timestamp = Number(packet.pts - this.origin) / 1000000;
|
|
44
|
+
if (!Number.isFinite(timestamp) || timestamp < 0 || (this.pending && timestamp < this.pending.timestamp)) throw new Error('录屏时间戳异常');
|
|
45
|
+
if (this.pending) await this.flush(Math.max(0.000001, timestamp - this.pending.timestamp));
|
|
46
|
+
const data = this.first ? Buffer.concat([this.configBytes, packet.data]) : packet.data.slice();
|
|
47
|
+
this.pending = { data, key: packet.keyframe, timestamp };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private async flush(duration: number) {
|
|
51
|
+
if (!this.pending || !this.config) return;
|
|
52
|
+
const { data, key, timestamp } = this.pending;
|
|
53
|
+
const c = this.config;
|
|
54
|
+
const codec = `avc1.${[c.profileIndex, c.constraintSet, c.levelIndex].map(v => v.toString(16).padStart(2, '0')).join('')}`;
|
|
55
|
+
await this.source.add(new EncodedPacket(data, key ? 'key' : 'delta', timestamp, duration), this.first ? {
|
|
56
|
+
decoderConfig: { codec, codedWidth: c.croppedWidth, codedHeight: c.croppedHeight },
|
|
57
|
+
} : undefined);
|
|
58
|
+
this.first = false;
|
|
59
|
+
this.pending = undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
end() { this.endedMonotonic ??= performance.now(); }
|
|
63
|
+
|
|
64
|
+
async finish() {
|
|
65
|
+
if (!this.startedAt) throw new Error('未收到可录制的关键帧');
|
|
66
|
+
await this.flush(Math.max(1 / 15, ((this.endedMonotonic ?? performance.now()) - this.startedMonotonic) / 1000 - (this.pending?.timestamp || 0)));
|
|
67
|
+
this.source.close();
|
|
68
|
+
await this.output.finalize();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async cancel() { await this.output.cancel(); }
|
|
72
|
+
}
|
|
@@ -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,
|